Armature Validation P2
- Added Highlight Bone System in the 3D View, can be turned off in settings. - Added more bones to the acceptable bone lists. - Fixed issue with properties registrations and unregistration, the system is more rebust now. - Added a validate t-pose system - Added a detect bone scales system. - Fixed some translation strings - Armature validation now uses logger system.
This commit is contained in:
+405
-14
@@ -1,6 +1,9 @@
|
||||
import bpy
|
||||
from typing import Tuple, List, Dict, Set, Optional
|
||||
from bpy.types import Object, Bone
|
||||
import math
|
||||
from mathutils import Vector, Color
|
||||
from typing import Tuple, List, Dict, Set, Optional, Union
|
||||
from bpy.types import Object, Bone, Operator
|
||||
from ..core.common import get_armature_list, get_active_armature
|
||||
from ..core.translations import t
|
||||
from ..core.dictionaries import (
|
||||
standard_bones,
|
||||
@@ -9,23 +12,35 @@ from ..core.dictionaries import (
|
||||
acceptable_bone_hierarchy,
|
||||
acceptable_bone_names
|
||||
)
|
||||
from ..core.logging_setup import logger
|
||||
|
||||
def validate_armature(armature: Object) -> Tuple[bool, List[str], bool]:
|
||||
def validate_armature(armature: Object, detailed_messages: bool = False) -> Union[Tuple[bool, List[str], bool], Tuple[bool, List[str], bool, List[str], List[str], List[str]]]:
|
||||
"""
|
||||
Validates armature and returns (is_valid, messages, is_acceptable_standard)
|
||||
Validates armature and returns validation results
|
||||
"""
|
||||
logger.debug(f"Validating armature: {armature.name if armature else 'None'}")
|
||||
validation_mode = bpy.context.scene.avatar_toolkit.validation_mode
|
||||
messages: List[str] = []
|
||||
hierarchy_messages: List[str] = []
|
||||
non_standard_messages: List[str] = []
|
||||
scale_messages: List[str] = []
|
||||
|
||||
if validation_mode == 'NONE':
|
||||
return True, [], False
|
||||
logger.debug("Validation mode is NONE, skipping validation")
|
||||
if detailed_messages:
|
||||
return True, [], False, [], [], []
|
||||
else:
|
||||
return True, [], False
|
||||
|
||||
if not armature or armature.type != 'ARMATURE' or not armature.data.bones:
|
||||
return False, [t("Armature.validation.basic_check_failed")], False
|
||||
logger.warning("Basic armature check failed")
|
||||
if detailed_messages:
|
||||
return False, [t("Armature.validation.basic_check_failed")], False, [], [], []
|
||||
else:
|
||||
return False, [t("Armature.validation.basic_check_failed")], False
|
||||
|
||||
found_bones: Dict[str, Bone] = {bone.name: bone for bone in armature.data.bones}
|
||||
logger.debug(f"Found {len(found_bones)} bones in armature")
|
||||
is_acceptable = check_acceptable_standards(found_bones)
|
||||
|
||||
# List all bones in armature
|
||||
@@ -39,31 +54,46 @@ def validate_armature(armature: Object) -> Tuple[bool, List[str], bool]:
|
||||
|
||||
if missing_bones:
|
||||
missing_list = "\n".join([f"- {bone}" for bone in missing_bones])
|
||||
logger.warning(f"Missing essential bones: {', '.join(missing_bones)}")
|
||||
hierarchy_messages.append(t("Armature.validation.missing_bones", bones=missing_list))
|
||||
|
||||
if validation_mode == 'STRICT':
|
||||
logger.debug("Performing strict validation")
|
||||
# Add scale issue detection in STRICT mode
|
||||
scale_issues = detect_scale_issues(found_bones)
|
||||
if scale_issues:
|
||||
logger.warning(f"Found {len(scale_issues)} scale issues")
|
||||
# CHANGE: Don't combine into a single string, keep as separate items
|
||||
scale_messages.extend(scale_issues)
|
||||
|
||||
# Validate bone hierarchy
|
||||
for parent, child in bone_hierarchy:
|
||||
if parent in found_bones and child in found_bones:
|
||||
if not validate_bone_hierarchy(found_bones, parent, child):
|
||||
logger.warning(f"Invalid hierarchy: {parent} -> {child}")
|
||||
hierarchy_messages.append(t("Armature.validation.invalid_hierarchy",
|
||||
parent=parent, child=child))
|
||||
|
||||
# Validate symmetry
|
||||
logger.debug("Validating bone symmetry")
|
||||
symmetry_pairs = [('arm', 'L', 'R'), ('leg', 'L', 'R')]
|
||||
for base, left, right in symmetry_pairs:
|
||||
if not validate_symmetry(found_bones, base, left, right):
|
||||
logger.warning(f"Asymmetric bones found: {base}")
|
||||
hierarchy_messages.append(t("Armature.validation.asymmetric_bones", bone=base))
|
||||
|
||||
|
||||
if (not validate_symmetry(found_bones, 'hand', 'L', 'R') and
|
||||
not validate_symmetry(found_bones, 'wrist', 'L', 'R')):
|
||||
logger.warning("Asymmetric hand/wrist bones found")
|
||||
hierarchy_messages.append(t("Armature.validation.asymmetric_hand_wrist"))
|
||||
|
||||
|
||||
# Validate finger hierarchies
|
||||
logger.debug("Validating finger hierarchies")
|
||||
for side in ['left', 'right']:
|
||||
for finger_chain in finger_hierarchy[side]:
|
||||
if all(bone in found_bones for bone in finger_chain):
|
||||
if not validate_finger_chain(found_bones, finger_chain):
|
||||
logger.warning(f"Invalid finger hierarchy: {finger_chain[0]}")
|
||||
hierarchy_messages.append(t("Armature.validation.invalid_finger", finger=finger_chain[0]))
|
||||
|
||||
# Non-standard bones check
|
||||
@@ -83,27 +113,39 @@ def validate_armature(armature: Object) -> Tuple[bool, List[str], bool]:
|
||||
non_standard_bones.append(bone_name)
|
||||
|
||||
if non_standard_bones:
|
||||
logger.warning(f"Found {len(non_standard_bones)} non-standard bones")
|
||||
non_standard_list = "\n".join([f"- {bone}" for bone in non_standard_bones])
|
||||
non_standard_messages.append(t("Armature.validation.non_standard_bones", bones=non_standard_list))
|
||||
|
||||
# Combine messages in correct order
|
||||
messages.extend(non_standard_messages)
|
||||
messages.extend(hierarchy_messages)
|
||||
|
||||
is_valid = len(non_standard_messages) == 0 and len(hierarchy_messages) == 0
|
||||
is_valid = len(non_standard_messages) == 0 and len(hierarchy_messages) == 0 and len(scale_messages) == 0
|
||||
|
||||
if not is_valid and is_acceptable:
|
||||
if non_standard_bones:
|
||||
return False, messages, False
|
||||
logger.info("Armature has non-standard bones but is acceptable")
|
||||
if detailed_messages:
|
||||
return False, messages, False, hierarchy_messages, scale_messages, non_standard_messages
|
||||
else:
|
||||
return False, messages, False
|
||||
|
||||
logger.info("Armature meets acceptable standards")
|
||||
messages = [
|
||||
t("Armature.validation.acceptable_standard.success"),
|
||||
t("Armature.validation.acceptable_standard.note"),
|
||||
t("Armature.validation.acceptable_standard.option")
|
||||
]
|
||||
return True, messages, True
|
||||
|
||||
return is_valid, messages, False
|
||||
if detailed_messages:
|
||||
return True, messages, True, [], [], []
|
||||
else:
|
||||
return True, messages, True
|
||||
|
||||
logger.info(f"Armature validation complete. Valid: {is_valid}")
|
||||
if detailed_messages:
|
||||
return is_valid, messages, False, hierarchy_messages, scale_messages, non_standard_messages
|
||||
else:
|
||||
return is_valid, messages, False
|
||||
|
||||
def validate_bone_hierarchy(bones: Dict[str, Bone], parent_name: str, child_name: str) -> bool:
|
||||
"""Validate if there is a valid parent-child relationship between bones"""
|
||||
@@ -148,6 +190,7 @@ def validate_finger_chain(bones: Dict[str, Bone], chain: Tuple[str, ...]) -> boo
|
||||
|
||||
def check_acceptable_standards(bones: Dict[str, Bone]) -> bool:
|
||||
"""Check if armature matches acceptable non-standard hierarchy"""
|
||||
logger.debug("Checking for acceptable standards")
|
||||
# Check if bones exist in acceptable list
|
||||
for bone_category, acceptable_names in acceptable_bone_names.items():
|
||||
found = False
|
||||
@@ -156,12 +199,360 @@ def check_acceptable_standards(bones: Dict[str, Bone]) -> bool:
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
logger.debug(f"Missing acceptable bone for category: {bone_category}")
|
||||
return False
|
||||
|
||||
# Validate acceptable hierarchy
|
||||
for parent, child in acceptable_bone_hierarchy:
|
||||
if parent in bones and child in bones:
|
||||
if not validate_bone_hierarchy(bones, parent, child):
|
||||
logger.debug(f"Invalid acceptable hierarchy: {parent} -> {child}")
|
||||
return False
|
||||
|
||||
logger.debug("Armature meets acceptable standards")
|
||||
return True
|
||||
|
||||
def validate_tpose(armature):
|
||||
"""Validate if armature is in a proper T-pose"""
|
||||
logger.debug(f"Validating T-pose for armature: {armature.name if armature else 'None'}")
|
||||
if not armature or armature.type != 'ARMATURE':
|
||||
logger.warning("No valid armature for T-pose validation")
|
||||
return False, [t("Validation.tpose.no_armature")]
|
||||
|
||||
issues = []
|
||||
|
||||
if armature.mode == 'POSE':
|
||||
bones_collection = armature.pose.bones
|
||||
get_direction = lambda bone: bone.matrix.to_3x3().col[1].normalized()
|
||||
else:
|
||||
bones_collection = armature.data.bones
|
||||
get_direction = lambda bone: bone.y_axis
|
||||
|
||||
# Get left and right upper arm bones using standard bone names
|
||||
left_arm = None
|
||||
right_arm = None
|
||||
|
||||
left_arm_candidates = [standard_bones['left_arm']] # UpperArm.L
|
||||
if 'arm_l' in acceptable_bone_names:
|
||||
left_arm_candidates.extend(acceptable_bone_names['arm_l'])
|
||||
|
||||
right_arm_candidates = [standard_bones['right_arm']] # UpperArm.R
|
||||
if 'arm_r' in acceptable_bone_names:
|
||||
right_arm_candidates.extend(acceptable_bone_names['arm_r'])
|
||||
|
||||
for name in left_arm_candidates:
|
||||
if name in armature.data.bones:
|
||||
left_arm = armature.data.bones[name]
|
||||
logger.debug(f"Found left arm bone: {name}")
|
||||
break
|
||||
|
||||
for name in right_arm_candidates:
|
||||
if name in armature.data.bones:
|
||||
right_arm = armature.data.bones[name]
|
||||
logger.debug(f"Found right arm bone: {name}")
|
||||
break
|
||||
|
||||
# Check arm bones are horizontal
|
||||
if left_arm:
|
||||
direction = left_arm.y_axis
|
||||
if abs(direction.x) < 0.7: # Not pointing mostly along X axis
|
||||
logger.warning("Left arm is not horizontal")
|
||||
issues.append(t("Validation.tpose.left_arm_not_horizontal"))
|
||||
|
||||
if right_arm:
|
||||
direction = right_arm.y_axis
|
||||
if abs(direction.x) < 0.7: # Not pointing mostly along X axis
|
||||
logger.warning("Right arm is not horizontal")
|
||||
issues.append(t("Validation.tpose.right_arm_not_horizontal"))
|
||||
|
||||
spine = None
|
||||
spine_candidates = [standard_bones['spine']] # Spine
|
||||
if 'spine' in acceptable_bone_names:
|
||||
spine_candidates.extend(acceptable_bone_names['spine'])
|
||||
|
||||
for name in spine_candidates:
|
||||
if name in armature.data.bones:
|
||||
spine = armature.data.bones[name]
|
||||
logger.debug(f"Found spine bone: {name}")
|
||||
break
|
||||
|
||||
if spine:
|
||||
direction = spine.y_axis
|
||||
if abs(direction.z) < 0.7: # Not pointing mostly along Z axis
|
||||
logger.warning("Spine is not vertical")
|
||||
issues.append(t("Validation.tpose.spine_not_vertical"))
|
||||
|
||||
if issues:
|
||||
logger.warning(f"T-pose validation failed with {len(issues)} issues")
|
||||
return False, issues
|
||||
|
||||
logger.info("T-pose validation successful")
|
||||
return True, []
|
||||
|
||||
def detect_scale_issues(bones):
|
||||
"""Detect bones with abnormal scale (too small or too large)"""
|
||||
logger.debug("Detecting scale issues")
|
||||
scale_issues = []
|
||||
|
||||
# Calculate median bone length for reference (more robust than average)
|
||||
lengths = [bone.length for bone in bones.values()]
|
||||
lengths.sort()
|
||||
|
||||
if not lengths:
|
||||
logger.debug("No bones with length found")
|
||||
return []
|
||||
|
||||
median_length = lengths[len(lengths) // 2]
|
||||
|
||||
# Filter out zero-length bones for standard deviation calculation
|
||||
non_zero_lengths = [l for l in lengths if l > 0.0001]
|
||||
|
||||
if not non_zero_lengths:
|
||||
logger.debug("No non-zero length bones found")
|
||||
return []
|
||||
|
||||
mean = sum(non_zero_lengths) / len(non_zero_lengths)
|
||||
variance = sum((l - mean) ** 2 for l in non_zero_lengths) / len(non_zero_lengths)
|
||||
std_dev = math.sqrt(variance)
|
||||
|
||||
small_threshold = max(median_length * 0.05, mean - 3 * std_dev)
|
||||
large_threshold = min(median_length * 15, mean + 5 * std_dev)
|
||||
|
||||
logger.debug(f"Scale thresholds - small: {small_threshold}, large: {large_threshold}")
|
||||
|
||||
# Get finger bones from standard and acceptable bone dictionaries
|
||||
finger_bone_names = set()
|
||||
|
||||
for key in standard_bones:
|
||||
if any(finger in key.lower() for finger in ['thumb', 'index', 'middle', 'ring', 'pinky', 'finger']):
|
||||
finger_bone_names.add(standard_bones[key])
|
||||
|
||||
for key, names in acceptable_bone_names.items():
|
||||
if any(finger in key.lower() for finger in ['thumb', 'index', 'middle', 'ring', 'pinky', 'finger']):
|
||||
finger_bone_names.update(names)
|
||||
|
||||
for name, bone in bones.items():
|
||||
is_finger = (name in finger_bone_names or
|
||||
any(finger in name.lower() for finger in ['thumb', 'index', 'middle', 'ring', 'pinky', 'finger']))
|
||||
|
||||
if bone.length < small_threshold and not is_finger:
|
||||
logger.debug(f"Bone {name} is too small: {bone.length}")
|
||||
scale_issues.append(f"- {name}: {t('Validation.scale_issue.too_small')} ({bone.length:.4f})")
|
||||
elif bone.length > large_threshold:
|
||||
logger.debug(f"Bone {name} is too large: {bone.length}")
|
||||
scale_issues.append(f"- {name}: {t('Validation.scale_issue.too_large')} ({bone.length:.4f})")
|
||||
|
||||
logger.debug(f"Found {len(scale_issues)} scale issues")
|
||||
return scale_issues
|
||||
|
||||
def clear_bone_highlighting(armature: Object) -> None:
|
||||
"""Clear bone highlighting by removing bone collections and resetting colors"""
|
||||
logger.debug(f"Clearing bone highlighting for armature: {armature.name if armature else 'None'}")
|
||||
if not armature or armature.type != 'ARMATURE':
|
||||
logger.warning("No valid armature for clearing bone highlighting")
|
||||
return
|
||||
|
||||
current_mode = bpy.context.mode
|
||||
|
||||
collection_name = "Problem Bones"
|
||||
if collection_name in armature.data.collections:
|
||||
problem_collection = armature.data.collections[collection_name]
|
||||
armature.data.collections.remove(problem_collection)
|
||||
logger.debug("Removed problem bones collection")
|
||||
|
||||
for bone in armature.data.bones:
|
||||
bone.color.palette = 'DEFAULT'
|
||||
|
||||
if len(armature.data.collections) == 0:
|
||||
armature.data.show_bone_colors = False
|
||||
logger.debug("Disabled bone colors display")
|
||||
|
||||
logger.info("Bone highlighting cleared")
|
||||
return
|
||||
|
||||
class AvatarToolkit_OT_HighlightProblemBones(Operator):
|
||||
"""Highlight bones that fail validation in the 3D viewport"""
|
||||
bl_idname = "avatar_toolkit.highlight_problem_bones"
|
||||
bl_label = t("Validation.highlight_problem_bones")
|
||||
bl_description = t("Validation.highlight_problem_bones_desc")
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return get_active_armature(context) is not None
|
||||
|
||||
def execute(self, context):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
logger.warning("No active armature found for highlighting problem bones")
|
||||
self.report({'ERROR'}, t("Validation.no_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
logger.info(f"Highlighting problem bones for armature: {armature.name}")
|
||||
|
||||
current_mode = context.mode
|
||||
|
||||
if current_mode != 'OBJECT':
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
context.view_layer.objects.active = armature
|
||||
|
||||
# First remove all bone collections
|
||||
collection_name = "Problem Bones"
|
||||
if collection_name in armature.data.collections:
|
||||
problem_collection = armature.data.collections[collection_name]
|
||||
armature.data.collections.remove(problem_collection)
|
||||
logger.debug("Removed existing problem bones collection")
|
||||
|
||||
is_valid, messages, _ = validate_armature(armature)
|
||||
|
||||
if is_valid:
|
||||
logger.info("No validation issues found")
|
||||
self.report({'INFO'}, t("Validation.no_issues"))
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
return {'FINISHED'}
|
||||
|
||||
problem_collection = armature.data.collections.new(name="Problem Bones")
|
||||
logger.debug("Created new problem bones collection")
|
||||
armature.data.show_bone_colors = True
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
# Extract bone names from validation messages
|
||||
problem_bones = self._extract_problem_bones(messages)
|
||||
|
||||
# Assign bones to collection and set colors
|
||||
highlighted_count = 0
|
||||
for category, bone_names in problem_bones.items():
|
||||
for bone_name in bone_names:
|
||||
if bone_name in armature.data.edit_bones:
|
||||
bone = armature.data.edit_bones[bone_name]
|
||||
problem_collection.assign(bone)
|
||||
|
||||
if 'hierarchy' in category.lower():
|
||||
bone.color.palette = 'THEME09' # Orange
|
||||
elif 'scale' in category.lower():
|
||||
bone.color.palette = 'THEME03' # Yellow
|
||||
else:
|
||||
bone.color.palette = 'THEME01' # Red
|
||||
|
||||
highlighted_count += 1
|
||||
|
||||
logger.info(f"Highlighted {highlighted_count} problem bones")
|
||||
self.report({'INFO'}, t("Validation.highlighting_complete"))
|
||||
return {'FINISHED'}
|
||||
|
||||
def _extract_problem_bones(self, messages):
|
||||
problem_bones = {
|
||||
"Hierarchy Issues": [],
|
||||
"Scale Issues": [],
|
||||
"Missing Bones": []
|
||||
}
|
||||
|
||||
# Extract bone names from validation messages
|
||||
for message in messages:
|
||||
if isinstance(message, str):
|
||||
# Parse message to extract bone names
|
||||
for line in message.split('\n'):
|
||||
if '- ' in line:
|
||||
bone_name = line.split('- ')[1].strip()
|
||||
if ':' in bone_name: # Handle "bone_name: message" format
|
||||
bone_name = bone_name.split(':')[0].strip()
|
||||
|
||||
if 'hierarchy' in message.lower():
|
||||
problem_bones["Hierarchy Issues"].append(bone_name)
|
||||
elif 'scale' in message.lower():
|
||||
problem_bones["Scale Issues"].append(bone_name)
|
||||
else:
|
||||
problem_bones["Missing Bones"].append(bone_name)
|
||||
|
||||
logger.debug(f"Extracted problem bones: {problem_bones}")
|
||||
return problem_bones
|
||||
|
||||
class AvatarToolkit_OT_ValidateTPose(Operator):
|
||||
"""Validate if armature is in a proper T-pose"""
|
||||
bl_idname = "avatar_toolkit.validate_tpose"
|
||||
bl_label = t("Validation.tpose.label")
|
||||
bl_description = t("Validation.tpose.desc")
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return get_active_armature(context) is not None
|
||||
|
||||
def execute(self, context):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
logger.warning("No active armature found for T-pose validation")
|
||||
self.report({'ERROR'}, t("Validation.no_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
logger.info(f"Validating T-pose for armature: {armature.name}")
|
||||
is_valid, messages = validate_tpose(armature)
|
||||
props = context.scene.avatar_toolkit
|
||||
props.tpose_validation_result = is_valid
|
||||
props.tpose_validation_messages.clear()
|
||||
|
||||
for msg in messages:
|
||||
item = props.tpose_validation_messages.add()
|
||||
item.name = msg
|
||||
|
||||
props.show_tpose_validation = True
|
||||
|
||||
if is_valid:
|
||||
logger.info("T-pose validation successful")
|
||||
self.report({'INFO'}, t("Validation.tpose.valid"))
|
||||
else:
|
||||
for msg in messages:
|
||||
self.report({'WARNING'}, msg)
|
||||
logger.warning("T-pose validation failed")
|
||||
self.report({'WARNING'}, t("Validation.tpose.warning"))
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
class AvatarToolkit_OT_ClearBoneHighlighting(Operator):
|
||||
"""Clear bone highlighting and reset bone colors"""
|
||||
bl_idname = "avatar_toolkit.clear_bone_highlighting"
|
||||
bl_label = t("Validation.clear_bone_highlighting")
|
||||
bl_description = t("Validation.clear_bone_highlighting_desc")
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return get_active_armature(context) is not None
|
||||
|
||||
def execute(self, context):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
logger.warning("No active armature found for clearing bone highlighting")
|
||||
self.report({'ERROR'}, t("Validation.no_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
logger.info(f"Clearing bone highlighting for armature: {armature.name}")
|
||||
current_mode = context.mode
|
||||
|
||||
# Switch to object mode as collection editing is not possible in edit mode
|
||||
if current_mode != 'OBJECT':
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
context.view_layer.objects.active = armature
|
||||
|
||||
collection_name = "Problem Bones"
|
||||
if collection_name in armature.data.collections:
|
||||
# Remove the collection
|
||||
problem_collection = armature.data.collections[collection_name]
|
||||
armature.data.collections.remove(problem_collection)
|
||||
logger.debug("Removed problem bones collection")
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
# Reset all bone colors
|
||||
for bone in armature.data.edit_bones:
|
||||
bone.color.palette = 'DEFAULT'
|
||||
|
||||
# Turn off bone colors display if no other collections are using it
|
||||
if len(armature.data.collections) == 0:
|
||||
armature.data.show_bone_colors = False
|
||||
logger.debug("Disabled bone colors display")
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
logger.info("Bone highlighting cleared")
|
||||
self.report({'INFO'}, t("Validation.highlighting_cleared"))
|
||||
return {'FINISHED'}
|
||||
|
||||
Reference in New Issue
Block a user