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

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,185 @@
import bpy
import re
from mathutils import Matrix, Vector, Quaternion
from ..util import can_apply_scale,make_fcurve_compound,get_bone_groups
def convert_m2_bones():
def fix_scale(matrix,curves,keyframe_count):
if not can_apply_scale(curves,keyframe_count):
return (True,'Non-uniform scaling')
for i in range(keyframe_count):
def co(j): return fcurves[j].keyframe_points[i].co
# read vector defining the old rotation
vec = Vector((co(0)[1], co(1)[1], co(2)[1]))
# TODO: CHANGE VECTOR USING 'matrix' HERE SOMEHOW
# write vector back
co(0)[1] = vec.x
co(1)[1] = vec.y
co(2)[1] = vec.z
return (False,'')
def fix_rotation(matrix,fcurves,keyframe_count):
def quat_dist(q1,q2):
# takes polarity into account on purpose.
# we just want to do _mostly_ correct rotations,
# but there might be a better formula to use here.
return (
pow(q1.w-q2.w,2) +
pow(q1.x-q2.x,2) +
pow(q1.y-q2.y,2) +
pow(q1.z-q2.z,2))
last_quat = None
for i in range(keyframe_count):
def co(j): return fcurves[j].keyframe_points[i].co
q_in = Quaternion((co(0)[1], co(1)[1], co(2)[1], co(3)[1]))
axis,angle = q_in.to_axis_angle()
axis.rotate(matrix)
rot_q = Quaternion(axis,angle)
if last_quat is None:
last_quat = rot_q
else:
rot_q_neg = Quaternion(-rot_q)
dist = quat_dist(rot_q,last_quat)
neg_dist = quat_dist(rot_q_neg,last_quat)
last_quat = rot_q if dist <= neg_dist else rot_q_neg
co(0)[1] = last_quat.w
co(2)[1] = last_quat.x
co(1)[1] = -last_quat.y
co(3)[1] = last_quat.z
return (False,'')
def fix_location(matrix, fcurves,keyframe_count):
for i in range(keyframe_count):
def co(j): return fcurves[j].keyframe_points[i].co
vec = Vector((co(0)[1],co(1)[1], co(2)[1]))
vec.rotate(matrix)
co(1)[1] = vec.x
co(0)[1] = -vec.y
co(2)[1] = vec.z
return (False,'')
def fix_curves(name, matrix, fcurves, track_count, callback):
for i in range(track_count):
if not i in fcurves:
raise ValueError(f'Track index {i} missing in {name} fcurves')
keyframe_count = len(fcurves[0].keyframe_points)
for i,fcurve in fcurves.items():
cur_count = len(fcurve.keyframe_points)
if cur_count != keyframe_count:
raise ValueError(f'Track index {i} keyframe count ({cur_count}) is different from index 0 {keyframe_count}')
for i in range(keyframe_count):
time = fcurves[0].keyframe_points[i].co[0]
for j in range(track_count):
cur_time = fcurves[j].keyframe_points[i].co[0]
if cur_time != time:
raise ValueError(f'Track index {j} frame {j} has a different time value ({cur_time}) from index 0 ({time})')
return callback(matrix,fcurves,keyframe_count)
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.select_all(action='DESELECT')
fixed_vertices = 0
for obj in bpy.data.objects:
if obj.type != 'MESH' or obj.parent is None or obj.parent.type != 'ARMATURE':
continue
bone_names = [bone.name for bone in obj.parent.data.bones]
for vertex in obj.data.vertices:
groups = get_bone_groups(obj, vertex, bone_names)
for el in groups[4:]:
obj.vertex_groups[el.group].remove([vertex.index])
if len(groups) > 4:
fixed_vertices += 1
print(f'Removed overflowing groups for {fixed_vertices} vertices')
for action in bpy.data.actions:
removed_fcurves = []
for curve in action.fcurves:
if curve.data_path in ["location","rotation_euler","scale"]:
removed_fcurves.append(curve)
print(f'Removed fcurve "{curve.data_path}[{curve.array_index}]" from action {action.name}')
for curve in removed_fcurves:
action.fcurves.remove(curve)
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.transform_apply(location=True, rotation=True, scale=False)
bpy.ops.object.select_all(action='DESELECT')
changed_bones = {}
changed_objects = []
for obj in bpy.data.objects:
if obj.type != 'ARMATURE': continue
try:
obj.select_set(True)
except RuntimeError as e:
print(f"Unable to select armature {obj.name}, not converting it")
continue
changed_objects.append(obj)
#this was enabled in 3.0 and worked better i think
bpy.ops.object.mode_set(mode='EDIT')
for bone in obj.data.edit_bones:
if bone.use_connect:
bone.use_connect = False
for bone in obj.data.edit_bones:
bone.use_connect = False
bone.roll = 0
bone.tail = bone.head + Vector((1,0,0))
changed_bones[bone.name] = Matrix(obj.data.bones[bone.name].matrix_local)
bpy.ops.object.mode_set(mode='OBJECT')
for action in bpy.data.actions:
fcurve_compounds = make_fcurve_compound(action.fcurves,
lambda path: path.startswith('pose.bones')
)
for key,fcurves in fcurve_compounds.items():
bone = re.search('"(.+?)"',key).group(1)
if not bone in changed_bones:
continue
matrix = changed_bones[bone]
curve_type = re.search('([a-zA-Z_]+)$',key).group(0)
remove_reason = None
should_remove = False
if curve_type == 'scale':
(should_remove,remove_reason) = fix_curves(key,matrix,fcurves,3,fix_scale)
if curve_type == 'location':
(should_remove,remove_reason) = fix_curves(key,matrix,fcurves,3,fix_location)
if curve_type == 'rotation_quaternion':
(should_remove,remove_reason) = fix_curves(key,matrix,fcurves,4,fix_rotation)
if should_remove:
print(f"Deleting incompatible fcurves {fcurve.data_path}: {remove_reason}")
for fcurve in fcurves.values():
action.fcurves.remove(fcurve)
for obj in changed_objects:
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
bpy.ops.object.mode_set(mode='POSE')
# clear other curves if needed
bpy.ops.pose.scale_clear()
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.select_all(action='DESELECT')
else:
for fcurve in fcurves.values():
fcurve.update()
@@ -0,0 +1,157 @@
from datetime import datetime
from enum import IntEnum
from ...ui.preferences import get_project_preferences
from . import m2_export_validation
class LogLevel(IntEnum):
"""Logging severity levels."""
ERROR = 1
WARN = 2
INFO = 3
DEBUG = 4
# ---------------------------
# Storage
# ---------------------------
errors = []
warnings = []
infos = []
debugs = []
master_log = []
# ---------------------------
# Utility
# ---------------------------
def clear():
"""Reset all stored logs."""
warnings.clear()
infos.clear()
errors.clear()
debugs.clear()
master_log.clear()
def get_verbosity_level() -> int:
"""
Retrieve verbosity setting from addon preferences.
Defaults to INFO level (3) if unavailable.
"""
try:
prefs = get_project_preferences()
return int(prefs.verbosity_level)
except Exception:
return 3 # INFO fallback
# ---------------------------
# Core logging
# ---------------------------
def add(level: LogLevel, msg: str, print_now: bool = False):
"""
Store a log entry and optionally print immediately.
Args:
level (LogLevel): severity of message
msg (str): log message string
print_now (bool): force console print immediately
"""
verbosity = get_verbosity_level()
timestamp = datetime.now().strftime("%H:%M:%S")
# --- Record per-level ---
if level == LogLevel.ERROR:
errors.append(msg)
elif level == LogLevel.WARN:
warnings.append(msg)
elif level == LogLevel.INFO:
infos.append(msg)
elif level == LogLevel.DEBUG:
debugs.append(msg)
# --- Append chronological record ---
master_log.append((level, msg, timestamp))
# --- Live print if enabled ---
if print_now and verbosity >= level:
print(f"[{timestamp}] [{level.name}] {msg}")
def _print_log_summary(title: str, validation_fn=None):
"""
Print combined summary for import/export logs.
Args:
title (str): label ("Import" or "Export")
validation_fn (callable, optional): optional validation hook
Returns:
tuple(bool, bool): (warnings_present, errors_present)
"""
verbosity = get_verbosity_level()
print("\n##############################################################")
print(f" {title} Log Summary")
print("##############################################################")
# --- Optional validation ---
if callable(validation_fn):
for name, descriptions, items in validation_fn():
print(f"\n== {name} ==")
for d in descriptions:
print(f" {d}")
for item in items:
add(LogLevel.WARN, f"[{name}] {item}", print_now=True)
# --- Errors & Warnings ---
if errors:
print(f"\n== {title} Errors ==")
for msg in errors:
print(f" [ERROR] {msg}")
if warnings:
print(f"\n== {title} Warnings ==")
for msg in warnings:
print(f" [WARN] {msg}")
# --- Full chronological log ---
print(f"\n== {title} Log ==")
for level, msg, timestamp in master_log:
if verbosity >= level:
print(f" [{timestamp}] [{level.name}] {msg}")
# --- Summary footer ---
print("\n##############################################################")
print(f" Summary: {len(errors)} errors, {len(warnings)} warnings, "
f"{len(infos)} info, {len(debugs)} debug messages")
print("##############################################################\n")
return bool(warnings), bool(errors)
def print_export_log():
"""
Print export summary and run export validation checks.
Returns: (warnings_present, errors_present)
"""
return _print_log_summary("Export", validation_fn=m2_export_validation.run_validations)
def print_import_log():
"""
Print import summary only.
Returns: (warnings_present, errors_present)
"""
return _print_log_summary("Import")
# ---------------------------
# Shorthands
# ---------------------------
error = lambda msg, print_now=False, **kw: add(LogLevel.ERROR, msg, print_now=print_now, **kw)
warn = lambda msg, print_now=False, **kw: add(LogLevel.WARN, msg, print_now=print_now, **kw)
info = lambda msg, print_now=False, **kw: add(LogLevel.INFO, msg, print_now=print_now, **kw)
debug = lambda msg, print_now=False, **kw: add(LogLevel.DEBUG, msg, print_now=print_now, **kw)
@@ -0,0 +1,255 @@
import bpy
from ..util import get_bone_groups
# ---------------------------
# Scene validation functions
# ---------------------------
def wrong_scene_type():
name = "Wrong Scene Type"
description = [
"Issue: The scene type is set to WMO instead of M2",
"Fix: Change the scene type to 'M2' in the top-right corner of Blender"
]
items = []
if not bpy.context.scene:
items.append("Wrong scene: There is no scene object, please report this (unexpected).")
elif bpy.context.scene.wow_scene.type != 'M2':
items.append(f"Wrong scene: Type is {bpy.context.scene.wow_scene.type} but should be M2")
return (name, description, items)
def transformed_objects():
name = "Transformed Objects"
description = [
"Issue: Objects in the scene are transformed (moved, rotated, or scaled).",
"Fix: Run the 'Convert Bones To WoW' command and fix any issues it causes."
]
items = []
def vec_eq(n, q1, q2):
for i in range(n):
if q1[i] != q2[i]:
return False
return True
def vec_str(value, names):
return " ".join([f"{names[i]}={value[i]}" for i in range(len(names))])
for obj in bpy.data.objects:
if obj.type not in ('ARMATURE', 'MESH'):
continue
def compare(name, names, val1, val2):
if not vec_eq(len(names), val1, val2):
items.append(f"Object {obj.name}'s {name} is {vec_str(val1, names)}, but should be {vec_str(val2, names)}")
vec_names = ['x', 'y', 'z']
quat_names = ['w', 'x', 'y', 'z']
compare("location", vec_names, obj.location, (0, 0, 0))
compare("scale", vec_names, obj.scale, (1, 1, 1))
if obj.rotation_mode == 'QUATERNION':
compare("quaternion rotation", quat_names, obj.rotation_quaternion, (1, 0, 0, 0))
elif obj.rotation_mode == 'AXIS_ANGLE':
compare("axis angle rotation", quat_names, obj.rotation_quaternion, (1, 0, 0, 0))
else:
compare("euler rotation", vec_names, obj.rotation_euler, (0, 0, 0))
return (name, description, items)
def empty_textures():
name = "Empty Textures"
description = [
"Issue: An M2 material has no texture set in any of its texture slots.",
"Effect: Will usually cause the model to become invisible ingame.",
"Note: This is not *always* an error, not all materials have textures."
]
items = []
for obj in bpy.data.objects:
if not hasattr(obj, "wow_m2_geoset") or obj.wow_m2_geoset.collision_mesh or not obj.material_slots:
continue
for slot in obj.material_slots:
mat = slot.material
if mat is None:
items.append(f"Object '{obj.name}' has an empty material slot.")
continue
if not hasattr(mat, "wow_m2_material"):
items.append(f"Material '{mat.name}' on object '{obj.name}' has no wow_m2_material attribute.")
continue
wow_mat = mat.wow_m2_material
if not hasattr(wow_mat, "texture_1"):
items.append(f"wow_m2_material on '{mat.name}' has no 'texture_1' attribute.")
continue
if wow_mat.texture_1 is None:
items.append(f"Object '{obj.name}', material '{mat.name}' has no M2 textures, may be invisible ingame.")
return (name, description, items)
def empty_texture_paths():
name = "Empty Texture Path"
description = [
"Issue: A model has an M2 material with a texture set that has no .blp path.",
"Effect: Will usually cause the model to become invisible ingame.",
"Fix: Find the material with the texture and fill the Texture Path.",
]
items = []
texture_maps = {}
for obj in bpy.data.objects:
if obj.type == 'MESH' and not obj.wow_m2_geoset.collision_mesh and obj.material_slots:
for slot in obj.material_slots:
if slot.material is None or not hasattr(slot.material, 'wow_m2_material'):
continue
mat = slot.material.wow_m2_material
for texture in [mat.texture_1, mat.texture_2]:
if texture and hasattr(texture, 'wow_m2_texture') and not texture.wow_m2_texture.path:
if texture.wow_m2_texture.texture_type == '0':
texture_maps.setdefault(texture.name, []).append(obj.name)
for texture, obj_names in texture_maps.items():
obj_str = ", ".join(obj_names)
items.append(f"Texture '{texture}' used by ({obj_str}) has no .blp path set.")
return (name, description, items)
def no_materials():
name = "No Materials"
description = [
"Issue: A model has no materials set.",
"Effect: The model will usually be invisible ingame.",
"Fix: Add at least one material to your model.",
"Note: Not always an error, some models don't require materials."
]
items = [
f"Object {obj.name} has no M2 materials (invisible ingame)."
for obj in bpy.data.objects
if obj.type == 'MESH' and not obj.wow_m2_geoset.collision_mesh and len(obj.material_slots) == 0
]
return (name, description, items)
def bone_constraints():
name = "Bone Constraints"
description = [
"Issue: A bone has constraints applied.",
"Effect: Will almost always break animations (WoW doesn't support bone constraints).",
"Fix: Remove bone constraints or bake animations into keyframes."
]
items = []
for obj in bpy.data.objects:
if obj.type != 'ARMATURE':
continue
for bone in obj.pose.bones:
for constraint in bone.constraints:
items.append(f"Bone {obj.name}.{bone.name} has constraint '{constraint.name}', usually a mistake.")
return (name, description, items)
def no_animation_pairs():
name = "No Animation Pairs"
description = [
"Issue: Animations in the Animation Editor have no object pairs.",
"Effect: No animation data will be exported for this sequence.",
"Fix: Add at least one object/action pair."
]
items = [
f"Sequence '{seq.name}' has no pairs."
for seq in bpy.context.scene.wow_m2_animations
if len(seq.anim_pairs) == 0 and "64" not in seq.flags
]
return (name, description, items)
def missing_animation_items():
name = "Missing Animation Items"
description = [
"Issue: Animation pairs lack an object or action.",
"Effect: No animation data will be written.",
"Fix: Assign both an object and an action."
]
items = []
for seq in bpy.context.scene.wow_m2_animations:
for pair in seq.anim_pairs:
if pair.object is None:
items.append(f"Sequence '{seq.name}' pair missing object.")
elif pair.action is None:
if pair.object.name not in ['CharInfoCam', 'CharInfoCam_Target', 'PortraitCam', 'PortraitCam_Target']:
items.append(f"Sequence '{seq.name}' pair '{pair.object.name}' missing action.")
return (name, description, items)
def non_primary_sequences():
name = "Non-primary Sequences"
description = [
"Issue: Non-primary sequences are unsupported.",
"Effect: Animation will break or crash the game.",
"Fix: Add the 'primary sequence' flag."
]
items = [
f"Sequence '{seq.name}' is missing the primary flag."
for seq in bpy.context.scene.wow_m2_animations
if not seq.is_global_sequence and "32" not in seq.flags
]
return (name, description, items)
def too_many_bone_groups():
name = "Too Many Bone Groups"
description = [
"Issue: Vertices are influenced by more than 4 bones.",
"Effect: Excess bones will be dropped, mesh will deform incorrectly.",
"Fix: Use 'Limit Bone Groups' or test the effect in-game."
]
items = []
for obj in bpy.data.objects:
if obj.type != 'MESH' or not obj.parent or obj.parent.type != 'ARMATURE':
continue
bone_names = [b.name for b in obj.parent.data.bones]
broken = sum(1 for v in obj.data.vertices if len(get_bone_groups(obj, v, bone_names)) > 4)
if broken > 0:
items.append(f"Object '{obj.name}' has {broken} vertices with too many bone groups.")
return (name, description, items)
def fcurves_transforming_objects():
name = "FCurves Transforming Objects"
description = [
"Issue: FCurves animate Blender objects directly, unsupported in M2.",
"Effect: Object transforms incorrectly in-game.",
"Fix: Run 'Convert Bones To WoW' and verify results."
]
items = []
for animation in bpy.context.scene.wow_m2_animations:
for pair in animation.anim_pairs:
if not pair.action:
continue
for curve in pair.action.fcurves:
if curve.data_path in ["location", "rotation_euler", "scale"]:
if pair.object and not pair.object.wow_m2_uv_transform.enabled:
items.append(f"FCurve '{curve.data_path}[{curve.array_index}]' in '{pair.action.name}' transforms object '{pair.object.name}'.")
return (name, description, items)
def run_validations():
"""Run all static validation checks and return structured results."""
validation_callbacks = [
wrong_scene_type,
transformed_objects,
empty_textures,
empty_texture_paths,
no_materials,
bone_constraints,
no_animation_pairs,
missing_animation_items,
non_primary_sequences,
too_many_bone_groups,
fcurves_transforming_objects,
]
results = []
for callback in validation_callbacks:
name, descriptions, items = callback()
if items:
results.append((name, descriptions, items))
return results