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

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,34 @@
import bpy
class ActiveObjectOverride:
"""
Overrides context of selected / active object for operators.
"""
__slots__ = ('_obj', '_context_override')
_obj: bpy.types.Object
def __init__(self, obj: bpy.types.Object, context: bpy.types.Context | None = None):
"""
Initialize context override helper class.
:param obj: Object to set as active for this override.
:param context: Current context.
"""
self._obj = obj
if context is None:
context = bpy.context
override = context.copy()
override['active_object'] = obj
override['selected_objects'] = [obj]
self._context_override = context.temp_override(**override)
def __enter__(self):
self._context_override.__enter__()
def __exit__(self, exc_type, exc_val, exc_tb):
self._context_override.__exit__(exc_type, exc_val, exc_tb)
@@ -0,0 +1,24 @@
import re
BL_ID_NAME_TEMPLATE = r'(.*)\.\d\d\d'
""" Template of ID data block names in Blender. E.g. name.001 and alike. """
def match_id_name(name: str, expected_name: str) -> bool:
"""
Matches the name on an ID data-block omitting the possible .xxx duplicate prefix.
:param name: Name to match.
:param expected_name: Expected base of ID data block name.
:return: True if matches, else False.
"""
if name == expected_name:
return True
match = re.match(BL_ID_NAME_TEMPLATE, name)
if match is None or match.group(1) != expected_name:
return False
return True
@@ -0,0 +1,157 @@
import bpy
from functools import partial
from typing import Protocol
from time import time
from ..third_party.boltons.funcutils import wraps
from ..utils.misc import show_message_box
from ..ui.locks import UIPropUpdateLock
def parametrized(dec):
def layer(*args, **kwargs):
def repl(f):
return dec(f, *args, **kwargs)
return repl
return layer
@parametrized
def delay_execution(func, delay_sec=1.0):
lock = False
def timer(*args, **kwargs):
nonlocal lock
lock = False
func(*args, **kwargs)
@wraps(func)
def wrapped(*args, **kwargs):
nonlocal lock
if not lock:
lock = True
bpy.app.timers.register(partial(timer, *args, **kwargs), first_interval=delay_sec)
return wrapped
@parametrized
def on_release(func, delay_sec=1.5):
exec_time = time()
def timer(*args, **kwargs):
nonlocal exec_time
if not abs(exec_time - time()) < delay_sec:
func(*args, **kwargs)
@wraps(func)
def wrapped(*args, **kwargs):
nonlocal exec_time
exec_time = time()
bpy.app.timers.register(partial(timer, *args, **kwargs), first_interval=max(1.0, delay_sec))
return wrapped
def no_recurse(func):
"""
Decorator to block the function's ability to indirectly call itself recursively.
:param func: Any func.
:return: Wrapped func.
"""
func.__no_recurse_lock = False
@wraps(func)
def wrapped(*args, **kwargs):
if func.__no_recurse_lock:
return
func.__no_recurse_lock = True
func(*args, **kwargs)
func.__no_recurse_lock = False
return wrapped
class StringFilterProtocol(Protocol):
"""
Protocol the filters for string_property_validator must implement.
"""
def __call__(self, string: str) -> None | str: ...
@no_recurse
def string_property_validator(self: bpy.types.PropertyGroup
, context: bpy.types.Context
, *
, name: str
, str_filter: StringFilterProtocol
, lockable: bool = True):
if lockable and UIPropUpdateLock().status:
return
cur_value = getattr(self, name)
filtered = str_filter(cur_value)
if filtered is None:
return
setattr(self, name, filtered)
def string_filter_internal_dir(string: str) -> None | str:
"""
Validate and attempt to fix the string that is supposed to represent internal WoW dir.
:param string: Any string.
:return: Fixed string or None if string was valid.
"""
if not string:
return None
slashes_fixed = False
if '/' in string:
string = string.replace('/', '\\')
slashes_fixed = True
if any(char == '.' for char in string):
show_message_box("Path must not contain '.' characters.", "Error", 'ERROR')
return ""
if any(char == ':' for char in string):
print("Path must not contain ':' characters. Path was probably added on import from a full disk filepath.")
return ""
# TODO: some characters like \ / : * ? " < > | are not valid path characters in windows
if any(char in ('*', '?', '"', '<', '>', '|') for char in string):
show_message_box("""Path must not contain * ? " < > | characters.""", "Error", 'ERROR')
return ""
try:
string.encode('ascii')
except UnicodeError:
show_message_box("Path must only use latin characters.", "Error", 'ERROR')
return ""
backslash_count = 0
for char in string:
if char == '\\':
backslash_count += 1
else:
backslash_count = 0
if backslash_count > 1:
show_message_box("Invalid path (multiple consecutive path separators)", "Error", 'ERROR')
return ""
if slashes_fixed:
return string
@@ -0,0 +1,358 @@
from .custom_object import CustomObject
from .bl_id_types_utils import match_id_name, BL_ID_NAME_TEMPLATE
from ..ui.message_stack import MessageStack
import bpy
import re
import os
from typing import Set, Sequence, Type
from pathlib import Path
def get_collection(model_collection: bpy.types.Collection
, col_name: str) -> bpy.types.Collection | None:
"""
Get child collection matching provided name excluding Blender's copy index (.xxx).
:param model_collection: Collection to search in.
:param col_name: Name of child collection.
:return: Collection if found, else None.
"""
# check if parent collection exists
if not model_collection:
raise KeyError("get_collection() called with Null WoW Model Collection.")
# attempt regular search
if (col := model_collection.children.get(col_name)) is not None:
return col
# attempt regex search
for col in model_collection.children:
match = re.match(BL_ID_NAME_TEMPLATE, col.name)
if match is None or match.group(1) != col_name:
continue
return col
def get_or_create_collection(model_collection: bpy.types.Collection
, col_name: str) -> bpy.types.Collection:
"""
Get child collection matching provided name excluding Blender's copy index (.xxx) or create it.
:param model_collection: Collection to search in. ('wow_wmo', 'wow_m2'...)
:param col_name: Name of child collection.
:return: Requested collection.
"""
# check if model_collection exists
if not model_collection:
# print("Error : WoW Model collection doesn't exist.")
raise KeyError("get_or_create_collection() called with Null WoW Model Collection.")
# can't create it because we don't have the type (wmo/m2/adt)
# This shouldn't fail if it is called with model_collection = get_current_wow_model_collection() if there is an active collection.
col = get_collection(model_collection, col_name)
if not col:
col = bpy.data.collections.new(col_name)
model_collection.children.link(col)
col.color_tag = 'COLOR_05'
return col
def obj_swap_collections(obj: bpy.types.Object, col_from: bpy.types.Collection, col_to: bpy.types.Collection):
"""
Safely unlink an object from one collection and link to another.
:param obj: Object to swap collection.
:param col_from: Collection from which to unlink.
:param col_to: Collection to which to link.
"""
try:
col_from.objects.unlink(obj)
except RuntimeError as e:
print(f"Error: exception occured while swapping object \"{obj.name}\" from collection \"{col_from.name}\" to"
"collection \"{col_to.name}\".\n{e}")
try:
col_to.objects.link(obj)
except RuntimeError as e:
print(f"Error: exception occured while swapping object \"{obj.name}\" from collection \"{col_from.name}\" to"
"collection \"{col_to.name}\".\n{e}")
def collection_swap_parent_collection(col: bpy.types.Collection
, col_from: bpy.types.Collection
, col_to: bpy.types.Collection):
"""
Safely unlink a collection from one collection, and link to another.
:param col: Collection to swap.
:param col_from: Collection from which to unlink.
:param col_to: Collection to which to link.
"""
try:
col_from.children.unlink(col)
except RuntimeError:
print(f"Error: exception occured while swapping parent of collection \"{col.name}\" from collection"
"\"{col_from.name}\" to collection \"{col_to.name}\".\n{e}")
try:
col_to.children.link(col)
except RuntimeError:
print(f"Error: exception occured while swapping parent of collection \"{col.name}\" from collection"
"\"{col_from.name}\" to collection \"{col_to.name}\".\n{e}")
def unlink_nested_collections(parent_col: bpy.types.Collection
, col: bpy.types.Collection) -> bool:
"""
Unlinks all nested collections from col to wmo_col.
:param parent_col: WMO collection to unlink irrelevant ones into.
:param col: Collection to sanitize.
:return True if had nested collections, else False.
"""
has_children = bool(len(col.children))
for child_col in col.children:
collection_swap_parent_collection(child_col, col, parent_col)
return has_children
def create_wmo_model_collection(scene: bpy.types.Scene,
filepath: str, wowpath: str)-> bpy.types.Collection:
# col = bpy.data.collections.new(os.path.basename(wowpath)) # filename (+ extension?)
filename = ''
if wowpath:
filename = Path(wowpath).stem
else:
filename = Path(filepath).stem
col = bpy.data.collections.new(filename) # filename only without extension
col.wow_wmo.enabled = True
def filepath_wmo(filepath):
filepath = filepath.lower()
filepath = Path(filepath)
filepath_parts = filepath.parts
try:
world_index = filepath_parts.index('world')
except ValueError:
return 'World directory not found in the filepath'
return str(Path(*filepath_parts[world_index:-1]))
col.wow_wmo.dir_path = filepath_wmo(filepath)
scene.collection.children.link(col)
# set the collection as active
layer_collection = bpy.context.view_layer.layer_collection.children[col.name]
bpy.context.view_layer.active_layer_collection = layer_collection
print("Created new WMO model Collection:" + col.name)
return col
""""
def create_wow_model_collection(scene: bpy.types.Scene
, id_prop: str) -> bpy.types.Collection | None:
Create a WoW model collection.
:param scene: Current scene.
:param id_prop: Identifier of the collection property for the given model type.
:return: Model (M2/WMO/ADT) collection
col = bpy.data.collections.new(id_prop) # just name it 'wow_wmo' ?
getattr(col, id_prop).enabled = True
# scene.collection = col
bpy.context.scene.collection.children.link(col)
layer_collection = bpy.context.view_layer.layer_collection.children[col.name]
bpy.context.view_layer.active_layer_collection = layer_collection
print("Created new WoW model Collection:" + col.name)
return col
"""
def get_current_wow_model_collection(scene: bpy.types.Scene
, id_prop: str) -> bpy.types.Collection | None:
"""
Gets currently active model collection.
:param scene: Current scene.
:param id_prop: Identifier of the collection property for the given model type.
:return: Model (M2/WMO/ADT) collection if found, else None.
"""
# check if there is an active collection at all
# don't print or it will spam when using it in panels
if not bpy.context.collection:
# print("Error: Couldn't find current WoW model collection: there is no active collection at all.")
return None
# return create_wow_model_collection(scene, id_prop)
act_col: bpy.types.Collection = bpy.context.collection
# print("active collection : " + act_col.name)
# check if the collection is a model collection itself
if act_col.name in scene.collection.children:
if getattr(act_col, id_prop).enabled:
return act_col
# print("Error: Couldn't find current WoW model collection: Active collection is not enabled to be a WoW "
# "Collection.")
return None
# return create_wow_model_collection(scene, id_prop)
# check if the collection is a child of some existing collections.
for col in scene.collection.children:
# double check if this fix is right and I udnerstood it correctly
if getattr(col, id_prop).enabled and act_col in col.children_recursive:
return col
# print("Error : Failed to find a WoW Model Collection.")
return None
# return create_wow_model_collection(scene, id_prop)
class SpecialCollection:
""" Defines rules for handling updates to special collections. """
__wbs_collection_name__: str
""" Name of the special collection. """
__wbs_root_collection_id_prop_name__: str
""" Name of the property group of a root collection. E.g. wow_wmo or wow_m2"""
__wbs_bl_object_types__: Set[str] = set()
""" Set of Blender object types allowed to be part of the collection.
(Used when no custom object type is defined.
"""
__wbs_custom_object_types__: Set[Type[CustomObject]] | None = None
""" Optional set of custom object types restricted to the collection. """
_required = { '__wbs_collection_name__', '__wbs_root_collection_id_prop_name__'}
""" Required fields to override. """
def __init_subclass__(cls, **kwargs):
for requirement in cls._required:
if not hasattr(cls, requirement):
raise NotImplementedError(f'"{cls.__name__}" must override "{requirement}".')
super().__init_subclass__(**kwargs)
@classmethod
def _get_root_collection(cls
, scene: bpy.types.Scene
, col: bpy.types.Collection) -> bpy.types.Collection | None:
"""
Gets parent WoW collection (recursive) if existing, or None.
:param scene: Current scene.
:param col: Collection to search the parent for.
:return: WMO parent collection or None if not found.
"""
for scene_col in scene.collection.children:
if not getattr(scene_col, cls.__wbs_root_collection_id_prop_name__).enabled:
continue
for child_col in scene_col.children_recursive:
if child_col == col:
return scene_col
@classmethod
def verify_root_collection_integrity(cls
, root_col: bpy.types.Collection
, special_collection_ts: Sequence[Type['SpecialCollection']]):
"""
Verifies presence of all special collections within a model collection.
:param root_col: Root model collection.
:param special_collection_ts: Special collections to ensure.
"""
n_special_cols = len(special_collection_ts)
status = [False] * n_special_cols
status_counter = 0
# search for matching collections
for child_col in root_col.children:
for i, special_col_t in enumerate(special_collection_ts):
if match_id_name(child_col.name, special_col_t.__wbs_collection_name__):
status[i] = True
status_counter += 1
break
if status_counter == n_special_cols:
break
# check if we found everything
if status_counter == n_special_cols:
return
for i, s in enumerate(status):
if not s:
special_collection_ts[i].create_collection(root_col)
@classmethod
def handle_collection_if_matched(cls
, scene: bpy.types.Scene
, update: bpy.types.DepsgraphUpdate
, special_collection_ts: Sequence[Type['SpecialCollection']]) -> bool:
"""
Handle update to collection of matches the provided depsgraph update.
:param scene: Current scene.
:param update: Depsgraph update.
:param special_collection_ts: Available collection types.
:return: True if handled, else False.
"""
collection: bpy.types.Collection = update.id.original
# test if collection matches the criteria of a special collection
if not match_id_name(collection.name, cls.__wbs_collection_name__):
return False
# 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)
for obj in collection.objects:
# handle custom object types
if cls.__wbs_custom_object_types__ is not None:
if 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 required custom object types.', icon='ERROR')
# handle normal object types
elif obj.type not in cls.__wbs_bl_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__}". Objects of type \'{obj.type}\' '
f'are not allowed.', icon='ERROR')
# handle nested collections
if unlink_nested_collections(root_col, collection):
MessageStack().push_message(msg=f'This collection cannot have nested collections.', icon='ERROR')
return True
@classmethod
def create_collection(cls
, parent_col: bpy.types.Collection):
"""
Creates a special collection in a parent collection.
:param parent_col: Parent collection.
"""
col = bpy.data.collections.new(name=cls.__wbs_collection_name__)
col.color_tag = 'COLOR_02'
parent_col.children.link(col)
@@ -0,0 +1,14 @@
def linear_to_srgb(c: float) -> float:
a = .055
if c <= .0031308:
return 12.92 * c
else:
return (1+a) * c ** (1/2.4) - a
def srgb_to_linear(c: float) -> float:
a = .055
if c <= .04045:
return c / 12.92
else:
return ((c+a) / (1+a)) ** 2.4
@@ -0,0 +1,312 @@
from ..utils.bl_context_utils import ActiveObjectOverride
from ..ui.message_stack import MessageStack
import bpy
import inspect
import importlib
import types
from typing import Set, Iterable, Dict, Callable, Type
from types import FunctionType
from functools import wraps
class CustomObject:
__wbs_bl_object_type__: str
""" Type of Blender bpy.types.Object. """
__wbs_prop_group_id__: str
""" String name of property group in the object to control data. """
__wbs_allowed_modes__: Set[str] = {}
""" Blender modes allowed for this object. 'OBJECT' mode is implicitly always allowed. """
__wbs_allow_scale__: bool = True
""" Allow scaling of object. """
__wbs_allow_non_uniform_scale__: bool = True
""" Allow non-uniform scaling of object. """
__wbs_allow_rotation__: bool = True
""" Allow rotating the object. """
__wbs_allow_modifiers__: bool = True
""" Allow modifiers to be used on the object. """
__wbs_allow_particles__: bool = True
""" Allow particle systems to be used on the object. """
__wbs_allow_physics__: bool = True
""" Allow physics to influence the object. """
__wbs_allow_constraints__: bool = True
""" Allow particle constraints to be used on the object. """
__wbs_allow_mesh_properties__: bool = True
""" Allow mesh properties to be available through this object. """
__wbs_allow_material_properties__: bool = True
""" Allow material properties to be available through this object. """
__wbs_banned_ops__: Iterable[str] = {}
""" List of banned operators, that lead to post_banned_op() function fired. By default removes an object."""
__wbs_on_mode_handlers__: Dict[str, Callable[[bpy.types.DepsgraphUpdate], None]] = {}
""" Dict of per-mode handlers run in specific modes. """
_required = {'__wbs_bl_object_type__', '__wbs_prop_group_id__'}
""" Required fields to override in derived classes. """
def __init_subclass__(cls, **kwargs):
for requirement in cls._required:
if not hasattr(cls, requirement):
raise NotImplementedError(f'"{cls.__name__}" must override "{requirement}".')
super().__init_subclass__(**kwargs)
@classmethod
def match(cls, obj: bpy.types.Object) -> bool:
"""
Checks if a Blender object satisfies the criteria of a custom object type.
:param obj: Blender object.
:return: True if matched, else False.
"""
# check Blender object type validity
if cls.__wbs_bl_object_type__ != obj.type:
return False
# check if special property group is enabled
try:
if not getattr(obj, cls.__wbs_prop_group_id__).enabled:
return False
except AttributeError:
pass
# check if any other special property is enabled
for subclass in CustomObject.__subclasses__():
if subclass is cls:
continue
try:
if getattr(obj, subclass.__wbs_prop_group_id__).enabled:
return False
except AttributeError:
pass
return True
@classmethod
def handle_object_if_matched(cls
, update: bpy.types.DepsgraphUpdate) -> bool:
"""
Handles the custom object update checks.
:param update: Depsgraph update for this object.
:return: True if handled, else False.
"""
obj: bpy.types.Object = update.id.original
# skip non-matching object
if not cls.match(obj):
return False
cls.on_each_update(update)
# check if any of the banned operators ran
if bpy.context.active_operator is not None and bpy.context.active_operator.bl_idname in cls.__wbs_banned_ops__:
cls.on_banned_operator_use(update, bpy.context.active_operator.bl_idname)
return True
# make sure we are not in a prohibited mode
if obj.mode != 'OBJECT' and obj.mode not in cls.__wbs_allowed_modes__:
with ActiveObjectOverride(obj):
MessageStack().push_message(msg=f'Object "{obj.name}" of custom type "{cls.__name__}" '
f'cannot use mode \'{obj.mode}\'', icon='ERROR')
bpy.ops.object.mode_set(mode='OBJECT')
cls.on_mode_change_failure(update, obj.mode)
# check validity of transform operations
if update.is_updated_transform:
# handle scale
if not cls.__wbs_allow_scale__:
obj.scale = (1, 1, 1)
elif not cls.__wbs_allow_non_uniform_scale__:
scale = (obj.scale[0] + obj.scale[1] + obj.scale[2]) / 3
obj.scale = (scale, scale, scale)
# handle rotation
if not cls.__wbs_allow_rotation__:
rot_mode = obj.rotation_mode
obj.rotation_mode = 'XYZ'
update.id.original.rotation_euler = (0, 0, 0)
obj.rotation_mode = rot_mode
# check if modifiers need cleaning
if not cls.__wbs_allow_modifiers__ and len(obj.modifiers):
obj.modifiers.clear()
MessageStack().push_message(msg=f'Object "{obj.name}" of custom type "{cls.__name__}" '
f'cannot use modifiers.', icon='ERROR')
# check if particles need cleaning
if not cls.__wbs_allow_particles__ and len(obj.particle_systems):
obj.particle_systems.clear()
MessageStack().push_message(msg=f'Object "{obj.name}" of custom type "{cls.__name__}" '
f'cannot use particle systems.', icon='ERROR')
# check if constraints need cleaning
if not cls.__wbs_allow_constraints__ and len(obj.constraints):
obj.constraints.clear()
MessageStack().push_message(msg=f'Object "{obj.name}" of custom type "{cls.__name__}" '
f'cannot use constraints.', icon='ERROR')
# execute mode handlers
mode_handler = cls.__wbs_on_mode_handlers__.get(update.id.original.mode)
if mode_handler is not None:
mode_handler(update)
return True
@classmethod
def on_mode_change_failure(cls, update: bpy.types.DepsgraphUpdate, attempted_mode: str, ):
"""
Called on attempt to enter a prohibited Blender mode. Override this to support handling this.
:param update: Current update.
:param attempted_mode: Attempted mode identifier.
"""
...
@classmethod
def on_banned_operator_use(cls, update: bpy.types.DepsgraphUpdate, bl_op_id_name: str):
"""
Called after the use of a banned operator. Deletes an object by default and displays an error message.
Override to support custom behavior.
:param update: Current update.
:param bl_op_id_name: Name of the executed / invoked operator.
"""
bpy.data.objects.remove(update.id.original, do_unlink=True)
op_class = getattr(bpy.types, bl_op_id_name)
MessageStack().push_message(msg=f'Object "{update.id.original.name}" of custom type "{cls.__name__}" '
f'was removed due to the use of prohibited action "{op_class.bl_lavel}" '
f'({op_class.bl_idname}). Use undo to cancel.', icon='ERROR')
@classmethod
def on_each_update(cls, update: bpy.types.DepsgraphUpdate) -> bool:
"""
Called on each update. Override for custom behavior.
:param update: Current update.
:return If returns True, handle_object_if_matched() proceeds to other checks, else stops after this step.
"""
return True
class CustomObjectInterfaceHandler:
""" This class handles patching of Blender's bpy.types.Panel subclasses to support custom context-aware polls. """
_altered_panels: Dict[Type[bpy.types.Panel], Callable[[Type[bpy.types.Panel], bpy.types.Context], None]] = {}
""" Panel classes and their original poll methods. """
@staticmethod
def copy_func(f: FunctionType, name=None):
"""
Copies a function.
:param f: Function to copy.
:param name: Custom name for copied function.
:return: a function with same code, globals, defaults, closure, and
name (or provide a new name).
"""
fn = types.FunctionType(f.__code__, f.__globals__, name or f.__name__,
f.__defaults__, f.__closure__)
# in case f was given attrs (note this dict is a shallow copy):
fn.__dict__.update(f.__dict__)
return fn
@classmethod
def register(cls):
""" Installs poll() hooks to matching panels. """
for typename in dir(bpy.types):
# skip this addon panels
if typename.startswith('WMO') or typename.startswith('M2'):
continue
try:
bpy_type_desc = getattr(bpy.types, typename)
if not inspect.isclass(bpy_type_desc) \
or not issubclass(bpy_type_desc, bpy.types.Panel) \
or not (not hasattr(bpy_type_desc, 'bl_parent_id') or not bpy_type_desc.bl_parent_id) \
or bpy_type_desc.bl_context not in {'modifier'
, 'physics'
, 'particle'
, 'constraint'
, 'data'
, 'material'}:
continue
bpy_module = importlib.import_module(getattr(bpy.types, typename).__module__)
bpy_type = getattr(bpy_module, typename)
except RuntimeError:
continue
except AttributeError:
continue
cls._altered_panels[bpy_type] = cls.copy_func(bpy_type.poll.__func__, name='poll')
for panel, panel_poll in cls._altered_panels.items():
@wraps(panel_poll)
def poll_override(cls, context):
try:
if not context.object:
return CustomObjectInterfaceHandler._altered_panels.get(cls)(cls, context)
for custom_obj in CustomObject.__subclasses__():
bl_context = cls.bl_context
if custom_obj.match(context.object):
if (not custom_obj.__wbs_allow_modifiers__ and bl_context == 'modifier') \
or (not custom_obj.__wbs_allow_physics__ and bl_context == 'physics') \
or (not custom_obj.__wbs_allow_particles__ and bl_context == 'particle') \
or (not custom_obj.__wbs_allow_constraints__ and bl_context == 'constraint') \
or (not custom_obj.__wbs_allow_mesh_properties__ and bl_context == 'data') \
or (not custom_obj.__wbs_allow_material_properties__ and bl_context == 'material'):
return False
except AttributeError:
pass
return CustomObjectInterfaceHandler._altered_panels.get(cls)(cls, context)
panel.poll = classmethod(poll_override)
@classmethod
def unregister(cls):
""" Uninstalls poll() hooks. """
for panel, orig_panel_poll in cls._altered_panels.items():
panel.poll = classmethod(cls.copy_func(orig_panel_poll, name='poll'))
cls._altered_panels.clear()
def register():
CustomObjectInterfaceHandler.register()
def unregister():
CustomObjectInterfaceHandler.unregister()
@@ -0,0 +1,317 @@
import bpy
import os
import sys
from mathutils import Vector
from collections import namedtuple
from ..pywowlib import WoWVersionManager
from ..pywowlib.archives.wow_filesystem import WoWFileData
from .. import PACKAGE_NAME
from ..ui.preferences import get_project_preferences
SequenceRecord = namedtuple('SequenceRecord', ['name', 'value', 'index'])
class Sequence(type):
def __new__(mcs, name, bases, dct):
dct['__fields__'] = list(dct.keys())[2:]
dct['_iter'] = 0
return super().__new__(mcs, name, bases, dct)
def __getitem__(self, item):
return getattr(self, self.__fields__[item])
def __iter__(self):
return self
def __next__(self):
if self._iter == len(self.__fields__):
self._iter = 0
raise StopIteration
item = SequenceRecord(self.__fields__[self._iter], getattr(self, self.__fields__[self._iter]), self._iter)
self._iter += 1
return item
def index(self, item):
return self.__fields__.index(item)
def singleton(class_):
instances = {}
def getinstance(*args, **kwargs):
if class_ not in instances:
instances[class_] = class_(*args, **kwargs)
return instances[class_]
return getinstance
def find_nearest_object(obj_, objects):
"""Get closest object to another object"""
dist = sys.float_info.max
result = None
for obj in objects:
obj_location_relative = obj.matrix_world.inverted() @ obj_.location
hit = obj.closest_point_on_mesh(obj_location_relative)
hit_dist = (obj_location_relative - hit[1]).length
if hit_dist < dist:
dist = hit_dist
result = obj
return result
def parse_bitfield(bitfield, last_flag=0x1000):
flags = set()
bit = 1
while bit <= last_flag:
if bitfield & bit:
flags.add(str(bit))
bit <<= 1
return flags
def construct_bitfield(flag_set):
bitfield = 0
for flag in flag_set:
bitfield |= int(flag)
return bitfield
def get_material_viewport_image(material):
""" Get viewport image assigned to a material """
for i in range(3):
try:
img = material.texture_slots[3 - i].texture.image
return img
except:
pass
return None
def load_game_data() -> WoWFileData:
WoWVersionManager().set_client_version(int(bpy.context.scene.wow_scene.version))
if not hasattr(bpy, 'wow_game_data'):
project_preferences = get_project_preferences()
bpy.wow_game_data = WoWFileData(project_preferences.wow_path, project_preferences.project_dir_path)
if not bpy.wow_game_data.files:
raise UserWarning("WoW game data is not loaded. Check settings.")
return bpy.wow_game_data
def custom_relpath(path, start):
if path.lower().startswith(start.lower()):
return path[len(start):].lstrip('\\')
else:
return resolve_outside_texture_path(path)
def resolve_texture_path(filepath: str) -> str:
filepath = os.path.splitext(bpy.path.abspath(filepath))[0] + ".blp"
prefs = get_project_preferences()
# TODO: project folder
rel_path = custom_relpath(filepath, prefs.cache_dir_path)
test_path = os.path.join(prefs.cache_dir_path, rel_path)
if os.path.exists(test_path) and os.path.isfile(test_path):
return rel_path.replace('/', '\\')
game_data = load_game_data()
path = (filepath, "")
rest_path = ""
while True:
path = os.path.split(path[0])
if not path[1]:
print("\nTexture \"{}\" not found.".format(path))
break
rest_path = os.path.join(path[1], rest_path)
rest_path = rest_path[:-1] if rest_path.endswith('\\') else rest_path
if os.name != 'nt':
rest_path_n = rest_path.replace('/', '\\')
else:
rest_path_n = rest_path
rest_path_n = rest_path_n[:-1] if rest_path_n.endswith('\\') else rest_path_n
if game_data.has_file(rest_path_n)[0]:
return rest_path_n
def resolve_outside_texture_path(filepath: str) -> str:
keywords = ["world\\", "dungeon\\", "creature\\", "interface\\", "item\\", "models\\", "spells\\", "textures\\", "tileset\\", "xtextures\\"]
lowercase_filepath = filepath.lower()
for keyword in keywords:
if keyword in lowercase_filepath:
index = lowercase_filepath.rfind(keyword)
extracted_path = lowercase_filepath[index:]
extracted_path = os.path.splitext(extracted_path)[0] + ".blp"
normalized_path = os.path.normpath(extracted_path)
return normalized_path
lowercase_filepath = os.path.splitext(bpy.path.abspath(lowercase_filepath))[0] + ".blp"
return lowercase_filepath
def resolve_outside_model_path(filepath: str) -> str:
keywords = ["world\\", "dungeon\\", "creature\\", "interface\\", "item\\", "models\\", "spells\\", "textures\\", "tileset\\", "xtextures\\"]
lowercase_filepath = filepath.lower()
for keyword in keywords:
if keyword in lowercase_filepath:
index = lowercase_filepath.rfind(keyword)
extracted_path = lowercase_filepath[index:]
extracted_path = os.path.splitext(extracted_path)[0] + ".m2"
normalized_path = os.path.normpath(extracted_path)
return normalized_path
else:
return None
def get_origin_position():
loc = bpy.context.scene.cursor.location
origin_loc = None
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
ctx = bpy.context.copy()
ctx['area'] = area
ctx['region'] = area.regions[-1]
bpy.ops.view3d.snap_cursor_to_selected(ctx)
origin_loc = bpy.context.scene.cursor.location
bpy.context.scene.cursor.location = loc
return origin_loc
def get_obj_boundbox_center(obj):
return obj.matrix_world @ (0.125 * sum((Vector(b) for b in obj.bound_box), Vector()))
def get_obj_radius(obj, bb_center):
mesh = obj.data
radius = 0.0
for vertex in mesh.vertices:
dist = (vertex.co - bb_center).length
if dist > radius:
radius = dist
return radius
def get_obj_boundbox_world(obj):
return tuple(obj.matrix_world @ Vector(obj.bound_box[0])), tuple(obj.matrix_world @ Vector(obj.bound_box[6]))
def get_objs_boundbox_world(objects):
corner1 = [32768, 32768, 32768]
corner2 = [-32768, -32768, -32768]
for obj in objects:
obj_bb_corner1, obj_bb_corner2 = get_obj_boundbox_world(obj)
for i, value in enumerate(obj_bb_corner1):
if value < corner1[i]:
corner1[i] = value
for i, value in enumerate(obj_bb_corner2):
if value > corner2[i]:
corner2[i] = value
return tuple(corner1), tuple(corner2)
def simplify_numbers(num):
num = float('{:.3g}'.format(num))
magnitude = 0
while abs(num) >= 1000:
magnitude += 1
num /= 1000.0
return '{}{}'.format('{:f}'.format(num).rstrip('0').rstrip('.'), ['', 'K', 'M', 'B', 'T'][magnitude])
def wrap_text(width, text):
lines = []
arr = text.split()
length_sum = 0
str_sum = ""
for var in arr:
length_sum += len(var) + 1
if length_sum <= width:
str_sum += " " + var
else:
lines.append(str_sum)
length_sum = 0
str_sum = var
if length_sum != 0:
lines.append(str_sum)
# lines.append(" " + arr[len(arr) - 1])
return lines
def draw_spoiler(layout, data, toggle_prop_name, name="", data1=None, layout_enabled=None, icon=None, align=True):
""" Draw a spoiler-like layout in Blender UI """
is_expanded = getattr(data, toggle_prop_name)
body = layout.box()
header = body.box()
header_row = header.row(align=True)
header_row.prop(data, toggle_prop_name, emboss=False, text='', icon='TRIA_DOWN' if is_expanded else 'TRIA_RIGHT')
if data1 and layout_enabled:
if icon:
header_row.label(text='', icon=icon)
header_row.prop(data1, layout_enabled, text=name)
else:
header_row.label(text=' ' + name if align else name, icon=icon)
if is_expanded:
content = body.column()
if data1 and layout_enabled:
content.enabled = getattr(data1, layout_enabled)
return content
def show_message_box(message = "", title = "Message Box", icon = 'INFO'):
def draw(self, context):
self.layout.label(text=message)
bpy.context.window_manager.popup_menu(draw, title=title, icon=icon)
@@ -0,0 +1,45 @@
import bpy
class NodeTreeBuilder:
def __init__( self
, node_tree : bpy.types.NodeTree
, x : float = -200
, y : float = -200
, delimeter : float = 300
):
self._x = x
self._y = y
self.tree = node_tree
self.delimeter = delimeter
self.purge_tree()
def purge_tree(self):
for node in self.tree.nodes:
self.tree.nodes.remove(node)
def add_node( self
, node_type : str
, node_name : str
, column : int
, row : int
, node_descr : str = ""
):
node = self.tree.nodes.new(node_type)
node.name = node_name
node.label = node_descr if node_descr else node_name
node.location = (self.delimeter * column + self._x, -self.delimeter * row + self._y)
return node
@@ -0,0 +1,60 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://b84ohqhssylna"
path="res://.godot/imported/placeholder.blend-6e69f9b134a01fbbcb802f96f00fa827.scn"
[deps]
source_file="res://reference/blender-wow-studio-3.4-1.1.0_Experimental/io_scene_wmo/utils/placeholder/placeholder.blend"
dest_files=["res://.godot/imported/placeholder.blend-6e69f9b134a01fbbcb802f96f00fa827.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,70 @@
import bpy
import blf
import os
import time
class WBS_Viewport_Text_Display(bpy.types.Operator):
bl_idname = "wbs.viewport_text_display"
bl_label = "Generic Text Display"
_timer = None
_draw_handler = None
_start_time = None
message: bpy.props.StringProperty()
font_size: bpy.props.IntProperty(default=24)
y_offset: bpy.props.IntProperty(default=67)
color:bpy.props.FloatVectorProperty(
size=4,
default=(1.0, 1.0, 1.0, 1.0)
)
def draw_callback_px(self, context, message, font_size, y_offset, color):
script_paths = bpy.utils.script_paths()
addons_path = os.path.join(script_paths[0], "addons")
addon_name = 'io_scene_wmo'
font_rel_path = os.path.join("fonts", "frizqt_ck.ttf")
font_path = os.path.join(addons_path, addon_name, font_rel_path)
font_id = blf.load(font_path)
blf.size(font_id, font_size, 72)
width, height = context.area.width, context.area.height
text_width, text_height = blf.dimensions(font_id, message)
text_x = (width - text_width) / 2
text_y = height - text_height - y_offset
blf.position(font_id, text_x, text_y, 0)
if color is None:
color = (1, 1, 1, 1)
blf.color(font_id, *color)
blf.enable(font_id, blf.SHADOW)
blf.shadow(font_id, 5, 0, 0, 0, 1)
blf.shadow_offset(font_id, 1, -2)
blf.draw(font_id, message)
blf.disable(font_id, blf.SHADOW)
def modal(self, context, event):
if (time.time() - self._start_time) > 3.5:
self.cancel(context)
return {'CANCELLED'}
return {'PASS_THROUGH'}
def invoke(self, context, event):
self._start_time = time.time()
args = (context, self.message, self.font_size, self.y_offset, self.color)
self._draw_handler = bpy.types.SpaceView3D.draw_handler_add(self.draw_callback_px, args, 'WINDOW', 'POST_PIXEL')
self._timer = context.window_manager.event_timer_add(0.1, window=context.window)
context.window_manager.modal_handler_add(self)
return {'RUNNING_MODAL'}
def cancel(self, context):
if self._draw_handler is not None:
bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, 'WINDOW')
self._draw_handler = None
if self._timer is not None:
context.window_manager.event_timer_remove(self._timer)
self._timer = None