Skip to content

Commit

Permalink
feat(skinned-mesh): add support for 'Child Of' constraint on bones (S…
Browse files Browse the repository at this point in the history
…tjerneIdioten#224)

Make it possible for bones in armatures to set a custom parent based on the 'Child Of' constraint target (PoseBone). Simplifies the workflow by allowing a single armature to manage all bones, instead of requiring one armature per bone.
  • Loading branch information
NMC-TBone authored Jan 14, 2025
1 parent c2e2461 commit 3ff9135
Show file tree
Hide file tree
Showing 3 changed files with 168 additions and 37 deletions.
38 changes: 31 additions & 7 deletions addon/i3dio/exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,13 +186,20 @@ def _export(i3d: I3D, objects: List[BlenderObject], sort_alphabetical: bool = Tr
objects_to_export = objects
if sort_alphabetical:
objects_to_export = sort_blender_objects_by_outliner_ordering(objects)
all_objects_to_export = [obj for root_obj in objects for obj in traverse_hierarchy(root_obj)]

new_objects_to_export = [obj for root_obj in objects for obj in traverse_hierarchy(root_obj)]
for obj in new_objects_to_export:
if obj not in i3d.all_objects_to_export:
i3d.all_objects_to_export.append(obj)

for blender_object in objects_to_export:
_add_object_to_i3d(i3d, blender_object, export_candidates=all_objects_to_export)
_add_object_to_i3d(i3d, blender_object)

if i3d.deferred_constraints:
_process_deferred_constraints(i3d)

def _add_object_to_i3d(i3d: I3D, obj: BlenderObject, parent: SceneGraphNode = None,
export_candidates: list = None) -> None:

def _add_object_to_i3d(i3d: I3D, obj: BlenderObject, parent: SceneGraphNode = None) -> None:
# Check if object should be excluded from export (including its children)
if hasattr(obj, 'i3d_attributes') and obj.i3d_attributes.exclude_from_export:
logger.info(f"Skipping [{obj.name}] and its children. Excluded from export.")
Expand Down Expand Up @@ -235,7 +242,7 @@ def _add_object_to_i3d(i3d: I3D, obj: BlenderObject, parent: SceneGraphNode = No
logger.warning(f"Armature modifier '{modifier.name}' on skinned mesh '{obj.name}' "
"has no armature object assigned. Exporting as a regular shape instead.")
break
elif modifier.object not in export_candidates:
elif modifier.object not in i3d.all_objects_to_export:
logger.warning(
f"Skinned mesh '{obj.name}' references armature '{modifier.object.name}', "
"but the armature is not included in the export hierarchy. "
Expand Down Expand Up @@ -280,7 +287,7 @@ def _add_object_to_i3d(i3d: I3D, obj: BlenderObject, parent: SceneGraphNode = No
# https://docs.blender.org/api/current/bpy.types.Object.html#bpy.types.Object.children
logger.debug(f"[{obj.name}] processing objects children")
for child in sort_blender_objects_by_outliner_ordering(obj.children):
_add_object_to_i3d(i3d, child, node, export_candidates)
_add_object_to_i3d(i3d, child, node)
logger.debug(f"[{obj.name}] no more children to process in object")


Expand All @@ -302,10 +309,27 @@ def _process_collection_objects(i3d: I3D, collection: bpy.types.Collection, pare
# a part of the collections objects. Which means that they would be added twice without this check. One for the
# object itself and one for the collection.
if child.parent is None:
_add_object_to_i3d(i3d, child, parent, export_candidates=collection.objects)
i3d.all_objects_to_export.append(child)
_add_object_to_i3d(i3d, child, parent)
logger.debug(f"[{collection.name}] no more objects to process in collection")


def traverse_hierarchy(obj: BlenderObject) -> List[BlenderObject]:
"""Recursively traverses an object hierarchy and returns all objects."""
return [obj] + [child for child in obj.children for child in traverse_hierarchy(child)]


def _process_deferred_constraints(i3d: I3D):
for armature, bone_object, target in i3d.deferred_constraints:
i3d.logger.debug(f"Processing deferred constraint for: {bone_object}, Target: {target}")

if target in i3d.processed_objects:
i3d.logger.debug(f"Target object '{target}' is included in the export hierarchy. Setting bone parent.")
bone_name = bone_object.name
bone = next((b for b in i3d.skinned_meshes[armature.name].bones if b.name == bone_name), None)

if bone is not None:
i3d.skinned_meshes[armature.name].update_bone_parent(None, custom_target=i3d.processed_objects[target],
bone=bone)
else:
i3d.logger.warning(f"Could not find bone {bone_name} in the armature's bone list!")
20 changes: 14 additions & 6 deletions addon/i3dio/i3d.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""This module contains shared functionality between the different modules of the i3dio addon"""
from __future__ import annotations # Enables python 4.0 annotation typehints fx. class self-referencing
from typing import (Union, Dict, List, Type, OrderedDict, Optional)
from typing import (Union, Dict, List, Type, OrderedDict, Optional, Tuple)
import logging
from . import xml_i3d

Expand Down Expand Up @@ -36,6 +36,8 @@ def __init__(self, name: str, i3d_file_path: str, conversion_matrix: mathutils.M
self.xml_elements['UserAttributes'] = xml_i3d.SubElement(self.xml_elements['Root'], 'UserAttributes')

self.scene_root_nodes = []
self.processed_objects: Dict[bpy.types.Object, SceneGraphNode] = {}
self.deferred_constraints: List[Tuple[bpy.types.Object, bpy.types.Bone, bpy.types.Object]] = []
self.conversion_matrix = conversion_matrix

self.shapes: Dict[Union[str, int], Union[IndexedTriangleSet, NurbsCurve]] = {}
Expand All @@ -50,15 +52,18 @@ def __init__(self, name: str, i3d_file_path: str, conversion_matrix: mathutils.M

self.depsgraph = depsgraph

self.all_objects_to_export: List[bpy.types.Object] = []

# Private Methods ##################################################################################################
def _next_available_id(self, id_type: str) -> int:
next_id = self._ids[id_type]
self._ids[id_type] += 1
return next_id

def _add_node(self, node_type: Type[SceneGraphNode], object_: Type[bpy.types.bpy_struct],
parent: Type[SceneGraphNode] = None) -> SceneGraphNode:
node = node_type(self._next_available_id('node'), object_, self, parent)
parent: Type[SceneGraphNode] = None, **kwargs) -> SceneGraphNode:
node = node_type(self._next_available_id('node'), object_, self, parent, **kwargs)
self.processed_objects[object_] = node
if parent is None:
self.scene_root_nodes.append(node)
self.xml_elements['Scene'].append(node.element)
Expand Down Expand Up @@ -89,9 +94,11 @@ def add_merge_group_node(self, merge_group_object: bpy.types.Object, parent: Sce

return node_to_return

def add_bone(self, bone_object: bpy.types.Bone, parent: Union[SkinnedMeshBoneNode, SkinnedMeshRootNode]) \
-> SceneGraphNode:
return self._add_node(SkinnedMeshBoneNode, bone_object, parent)
def add_bone(self, bone_object: bpy.types.Bone, parent: Union[SkinnedMeshBoneNode, SkinnedMeshRootNode],
is_child_of: bool = False, armature_object: bpy.types.Object = None,
target: bpy.types.Object = None) -> SceneGraphNode:
return self._add_node(SkinnedMeshBoneNode, bone_object, parent, is_child_of=is_child_of,
armature_object=armature_object, target=target)

# TODO: Rethink this to not include an extra argument for when the node is actually discovered.
# Maybe two separate functions instead? This is just hack'n'slash code at this point!
Expand Down Expand Up @@ -269,6 +276,7 @@ def export_to_i3d_file(self) -> None:
self.export_i3d_mapping()

def export_i3d_mapping(self) -> None:
self.logger.info(f"Exporting i3d mappings to {self.settings['i3d_mapping_file_path']}")
with open(bpy.path.abspath(self.settings['i3d_mapping_file_path']), 'r+') as xml_file:
vehicle_xml = []
i3d_mapping_idx = None
Expand Down
147 changes: 123 additions & 24 deletions addon/i3dio/node_classes/skinned_mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,38 @@
but it helps with debugging big trees and seeing the structure.
"""
from __future__ import annotations
from typing import (Union, Dict, List, Type, OrderedDict, Optional)
from collections import ChainMap
from typing import (Union, Dict, List)
from collections import (ChainMap, namedtuple)
import mathutils
import bpy
import math

from . import node
from .node import (TransformGroupNode, SceneGraphNode)
from .shape import (ShapeNode, EvaluatedMesh)
from ..i3d import I3D
from .. import xml_i3d

import math

AssignParentResult = namedtuple('AssignParentResult', ['parent', 'is_child_of', 'deferred'])


class SkinnedMeshBoneNode(TransformGroupNode):
def __init__(self, id_: int, bone_object: bpy.types.Bone,
i3d: I3D, parent: SceneGraphNode):
def __init__(self, id_: int, bone_object: bpy.types.Bone, i3d: I3D, parent: SceneGraphNode,
is_child_of: bool = False, armature_object: bpy.types.Object = None, target: bpy.types.Object = None):
self.is_child_of = is_child_of
self.armature_object = armature_object
self.target = target # Used for deferred constraints
super().__init__(id_=id_, empty_object=bone_object, i3d=i3d, parent=parent)

def _matrix_to_i3d_space(self, matrix: mathutils.Matrix) -> mathutils.Matrix:
return self.i3d.conversion_matrix @ matrix @ self.i3d.conversion_matrix.inverted()

@property
def _transform_for_conversion(self) -> mathutils.Matrix:
"""
Calculate the bone's transformation matrix in I3D space, considering parenting and deferred constraints.
Handles scenarios like direct parenting, deferred CHILD_OF constraints, and collapsed armatures.
"""
if self.blender_object.parent and isinstance(self.blender_object.parent, bpy.types.Bone):
# For bones parented to other bones, matrix_local is relative to the parent.
# No transformation to I3D space is needed because the orientation is already relative to the parent bone.
Expand All @@ -42,14 +50,40 @@ def _transform_for_conversion(self) -> mathutils.Matrix:
bone_matrix = rot_fix @ bone_matrix.to_3x3().to_4x4()
bone_matrix.translation = translation

self.logger.debug(f"bone settings: {self.is_child_of} {self.target}")

if self.is_child_of and self.parent.blender_object is not None:
# Bone is parented to a CHILD_OF constraint target, collapse_armature doesn't matter here
# Multiply the bone's local transform with the inverse of the parent object's world matrix
# to correctly position it relative to its new parent
parent_matrix = self._matrix_to_i3d_space(self.parent.blender_object.matrix_world)
if self.armature_object is not None:
# If the armature object is also present, include its local matrix in the calculation
armature_matrix = self._matrix_to_i3d_space(self.armature_object.matrix_local)
return parent_matrix.inverted() @ armature_matrix @ bone_matrix
return parent_matrix.inverted() @ bone_matrix
elif self.target is not None:
# Bone is parented to a deferred CHILD_OF constraint target
# Multiply the bone's local transform with the inverse of the target's world matrix
# to correctly position it relative to its new parent
target_matrix = self._matrix_to_i3d_space(self.target.matrix_world)
return target_matrix.inverted() @ bone_matrix

# For bones parented directly to the armature, matrix_local already represents their transform
# relative to the armature, so no additional adjustments are needed.
if self.i3d.settings['collapse_armatures'] and self.parent.blender_object:
if self.i3d.settings['collapse_armatures'] and self.parent.blender_object is not None:
# If collapse_armatures is enabled, the armature is removed in the I3D.
# The root bone replaces the armature in the hierarchy,
# so multiply its matrix with the armature matrix to preserve the correct transformation.
armature_matrix = self._matrix_to_i3d_space(self.parent.blender_object.matrix_local)
return armature_matrix @ bone_matrix

if self.armature_object is not None:
# In some rare cases when armature is collapsed and bone ends up being parented to scene root,
# we have to multiply the bone's matrix with its armature's world matrix to ensure correct positioning.
armature_matrix = self._matrix_to_i3d_space(self.armature_object.matrix_world)
return armature_matrix @ bone_matrix

# Return the bone's local transform unchanged, as it is already correct relative to the armature.
return bone_matrix

Expand All @@ -61,6 +95,7 @@ def __init__(self, id_: int, armature_object: bpy.types.Armature,
# but dicts should be ordered going forwards in python
self.bones: List[SkinnedMeshBoneNode] = list()
self.bone_mapping: Dict[str, int] = {}
self.armature_object = armature_object
# To determine if we just added the armature through a modifier lookup or knows its position in the scenegraph
self.is_located = False

Expand All @@ -71,33 +106,97 @@ def __init__(self, id_: int, armature_object: bpy.types.Armature,
self._add_bone(bone, self)

def add_i3d_mapping_to_xml(self):
"""Wont export armature mapping, if 'collapsing armatures' is enabled
"""
"""Wont export armature mapping, if 'collapsing armatures' is enabled"""
if not self.i3d.settings['collapse_armatures']:
super().add_i3d_mapping_to_xml()

def _add_bone(self, bone_object: bpy.types.Bone, parent: Union[SkinnedMeshBoneNode, SkinnedMeshRootNode]):
def _add_bone(self, bone_object: bpy.types.Bone, parent: SceneGraphNode):
"""Recursive function for adding a bone along with all of its children"""
self.bones.append(self.i3d.add_bone(bone_object, parent))
target = None
is_child_of = False

# Check for CHILD_OF constraint
if (pose_bone := self.blender_object.pose.bones.get(bone_object.name)) is not None:
child_of = next((c for c in pose_bone.constraints if c.type == 'CHILD_OF'), None)
if child_of:
if not child_of.target:
self.logger.warning(f"CHILD_OF constraint on {bone_object.name} has no target. Ignoring.")
else:
target = child_of.target
result = self.assign_parent(bone_object, parent, target)
parent = result.parent
is_child_of = result.is_child_of

if result.deferred:
self.i3d.deferred_constraints.append((self.armature_object, bone_object, target))
self.logger.debug(f"Deferred constraint added for: {bone_object}, target: {target}")

self.bones.append(self.i3d.add_bone(bone_object, parent, is_child_of=is_child_of,
armature_object=self.armature_object, target=target))
current_bone = self.bones[-1]
self.bone_mapping[bone_object.name] = current_bone.id

for child_bone in bone_object.children:
self._add_bone(child_bone, current_bone)

def update_bone_parent(self, parent):
for bone in self.bones:
if bone.parent == self:
self.element.remove(bone.element)
self.children.remove(bone)
if parent is not None:
bone.parent = parent
parent.add_child(bone)
parent.element.append(bone.element)
else:
bone.parent = None
self.i3d.scene_root_nodes.append(bone)
self.i3d.xml_elements['Scene'].append(bone.element)
def update_bone_parent(self, parent: SceneGraphNode = None,
custom_target: SceneGraphNode = None, bone: SkinnedMeshBoneNode = None):
"""Updates the parent of bones"""
if custom_target is not None and bone is not None:
self._assign_bone_parent(bone, custom_target)
else:
for bone in self.bones:
if bone.parent == self:
new_parent = parent or None
self._assign_bone_parent(bone, new_parent)

def _assign_bone_parent(self, bone: SkinnedMeshBoneNode, new_parent: SceneGraphNode):
"""Assigns a new parent to a bone and updates the scene graph"""
self.logger.debug(f"Updating parent for {bone} to {new_parent}")
# Remove bone from its current parent
if bone.parent == self:
self.logger.debug(f"Removing {bone} from current parent")
self.element.remove(bone.element)
self.children.remove(bone)

# Add to the new parent or scene root
if new_parent:
if bone in self.i3d.scene_root_nodes:
# If collapse armature is enabled the bone would have moved to the scene root,
# but since we are now moving it to a new parent we have to remove it from the scene root
# Or else we can get problems with i3dmappings
self.logger.debug(f"Removing {bone} from scene root")
self.i3d.scene_root_nodes.remove(bone)
self.i3d.xml_elements['Scene'].remove(bone.element)
self.logger.debug(f"Adding {bone} to {new_parent}")
bone.parent = new_parent
new_parent.add_child(bone)
new_parent.element.append(bone.element)
else:
self.logger.debug(f"Adding {bone} to scene root")
bone.parent = None
self.i3d.scene_root_nodes.append(bone)
self.i3d.xml_elements['Scene'].append(bone.element)

def assign_parent(self, bone_object: bpy.types.Bone, parent: SceneGraphNode,
target: bpy.types.Object) -> AssignParentResult:
"""
Assign the parent for a bone. Returns the assigned parent, whether the bone is a child of a target,
and whether the assignment is deferred.
"""
if target in self.i3d.processed_objects:
# Target is processed, use it as the parent
self.logger.debug(f"Target {target} is processed. Assigning as parent for {bone_object}.")
return AssignParentResult(parent=self.i3d.processed_objects[target], is_child_of=True, deferred=False)

if target not in self.i3d.all_objects_to_export:
# Target is not in the export list, fallback to the original parent
self.logger.debug(f"Target {target} is not in the export list. Using original parent for {bone_object}.")
return AssignParentResult(parent=parent, is_child_of=False, deferred=False)

# Defer the constraint and temporarily keep the original parent
self.logger.debug(f"Deferring CHILD_OF constraint for {bone_object}, target: {target}")
return AssignParentResult(parent=parent, is_child_of=False, deferred=True)


class SkinnedMeshShapeNode(ShapeNode):
Expand Down

0 comments on commit 3ff9135

Please sign in to comment.