diff --git a/README.md b/README.md index fb8617f2..4a8e896e 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ This add-on bundles and redistributes the following third-party components. * **OpenRigLogic** (DNA and RigLogic libraries) — © Epic Games, Inc., licensed under the [MIT License](https://github.com/EpicGames/OpenRigLogic/blob/main/LICENSE). * **Sentry SDK for Python** — © Functional Software, Inc. dba Sentry, licensed under the MIT License. +* **ufbx for Python** — MIT License. Epic Games, Unreal Engine, Fab, MetaHuman, RigLogic, and OpenRigLogic, and their associated design logos, are trademarks or registered trademarks of Epic Games, Inc. All other trademarks are the property of diff --git a/cspell.json b/cspell.json index 5dae8a51..913c6963 100644 --- a/cspell.json +++ b/cspell.json @@ -36,6 +36,7 @@ ], "words": [ "bpy", + "ufbx", "Rigify", ".npz", "cytoscape", @@ -223,7 +224,18 @@ "svd", "vmask", "vidx", - "regularizers" + "regularizers", + "Samuli", + "Raivio", + "pyufbx", + "uuv", + "XZY", + "YXZ", + "YZX", + "ZXY", + "ZYX", + "quats", + "interp" ] }, { diff --git a/docs/faq.md b/docs/faq.md index b004d20c..f80b4a04 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -19,9 +19,6 @@ Check these in order: Live RigLogic evaluation doesn't run before each frame when rendering. **Bake** the face board animation to keyframes first so the pose bones, shape keys, and mask values are stored on the timeline. See [Animation](./free-features/animation.md). -!!! note - It is now possible to disable `Batched Evaluations` in the addon preferences. This is currently experimental. Please report any issues with evaluation in the scene or while rendering with this option on. - ## Still stuck? Browse or search existing reports and open a new issue here: diff --git a/pyproject.toml b/pyproject.toml index d9f8069d..d942369d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ keywords = ["blender", "metahuman", "dna", "riglogic", "animation", "3d"] name = "character-dna-addon" readme = "README.md" requires-python = ">=3.11,<3.14" -version = "0.11.3" +version = "0.12.2" [project.optional-dependencies] dev = [ @@ -117,6 +117,21 @@ output = "reports/coverage/results.xml" [tool.hatch.build.targets.wheel] packages = ["src/addons/character_dna"] +# ============================================================================= +# uv Sources +# ============================================================================= +# ufbx Python bindings used to read FBX animation. Resolved from the same +# pre-built wheels the extension bundles +[tool.uv.sources] +pyufbx = [ + {path = "src/addons/character_dna/wheels/pyufbx-0.0.0-cp311-cp311-win_amd64.whl", marker = "sys_platform == 'win32' and python_full_version >= '3.11' and python_full_version < '3.12'"}, + {path = "src/addons/character_dna/wheels/pyufbx-0.0.0-cp313-cp313-win_amd64.whl", marker = "sys_platform == 'win32' and python_full_version >= '3.13' and python_full_version < '3.14'"}, + {path = "src/addons/character_dna/wheels/pyufbx-0.0.0-cp311-cp311-macosx_11_0_arm64.whl", marker = "sys_platform == 'darwin' and python_full_version >= '3.11' and python_full_version < '3.12'"}, + {path = "src/addons/character_dna/wheels/pyufbx-0.0.0-cp313-cp313-macosx_11_0_arm64.whl", marker = "sys_platform == 'darwin' and python_full_version >= '3.13' and python_full_version < '3.14'"}, + {path = "src/addons/character_dna/wheels/pyufbx-0.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", marker = "sys_platform == 'linux' and python_full_version >= '3.11' and python_full_version < '3.12'"}, + {path = "src/addons/character_dna/wheels/pyufbx-0.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", marker = "sys_platform == 'linux' and python_full_version >= '3.13' and python_full_version < '3.14'"}, +] + # ============================================================================= # MyPy Configuration (optional, for those who prefer mypy) # ============================================================================= @@ -409,6 +424,7 @@ dev = [ "genbadge[all]>=1.1.2", "mkdocs>=1.6.1", "poly-hammer-utils", + "pyufbx", "ruff>=0.8.0", "pre-commit>=4.0.0", "pip>=25.3", diff --git a/src/addons/character_dna/__init__.py b/src/addons/character_dna/__init__.py index b7e6bb17..84018c26 100644 --- a/src/addons/character_dna/__init__.py +++ b/src/addons/character_dna/__init__.py @@ -17,7 +17,7 @@ bl_info = { "name": "Character DNA", "author": "Poly Hammer", - "version": (0, 11, 3), + "version": (0, 12, 2), "blender": (4, 5, 0), "location": "File > Import > MetaHuman DNA", "description": ( @@ -44,6 +44,7 @@ operators.AppendOrLinkCharacter, operators.ImportFaceBoardAnimation, operators.ImportComponentAnimation, + operators.ConfirmAnimationImport, operators.BakeFaceBoardAnimation, operators.BakeComponentAnimation, operators.TestSentry, diff --git a/src/addons/character_dna/blender_manifest.toml b/src/addons/character_dna/blender_manifest.toml index c87b5363..12d14959 100644 --- a/src/addons/character_dna/blender_manifest.toml +++ b/src/addons/character_dna/blender_manifest.toml @@ -11,7 +11,7 @@ schema_version = "1.0.0" id = "character_dna" name = "Character DNA" tagline = "Customize MetaHuman DNA files with RigLogic evaluation support" -version = "0.11.3" +version = "0.12.2" type = "add-on" # ============================================================================= @@ -32,12 +32,14 @@ blender_version_max = "5.3.0" # License Information # ============================================================================= # The add-on's own code is licensed under GPL-3.0. It also bundles and -# redistributes the OpenRigLogic DNA/RigLogic Python bindings and the Sentry SDK. +# redistributes the OpenRigLogic DNA/RigLogic Python bindings, the ufbx Python +# bindings, and the Sentry SDK. license = ["GPL-3.0 license", "MIT license"] copyright = [ "2026 Poly Hammer", "2026 Epic Games, Inc. (OpenRigLogic DNA and RigLogic libraries, MIT License)", "2018 Functional Software, Inc. dba Sentry (sentry-sdk, MIT License)", + "2020 Samuli Raivio (ufbx, MIT License)", ] # ============================================================================= @@ -95,8 +97,20 @@ permissions = { files = "Import and export DNA files", network = "Error reportin # Wheels (Python Dependencies) # ============================================================================= # Python packages required by this extension -# These will be installed automatically when the extension is enabled -wheels = ["./wheels/sentry_sdk-2.52.0-py2.py3-none-any.whl"] +# These will be installed automatically when the extension is enabled. +# The pyufbx wheels are native and therefore per-platform and per-Python-ABI: the +# packager keeps only the wheels matching each platform in that platform's zip, and +# Blender then installs the cp311 (Blender 4.5) or cp313 (Blender 5.x) wheel that +# matches its own interpreter. +wheels = [ + "./wheels/sentry_sdk-2.52.0-py2.py3-none-any.whl", + "./wheels/pyufbx-0.0.0-cp311-cp311-win_amd64.whl", + "./wheels/pyufbx-0.0.0-cp311-cp311-macosx_11_0_arm64.whl", + "./wheels/pyufbx-0.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", + "./wheels/pyufbx-0.0.0-cp313-cp313-win_amd64.whl", + "./wheels/pyufbx-0.0.0-cp313-cp313-macosx_11_0_arm64.whl", + "./wheels/pyufbx-0.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", +] # ============================================================================= # Build Settings diff --git a/src/addons/character_dna/components/body.py b/src/addons/character_dna/components/body.py index b1ac90a2..aa89fe06 100644 --- a/src/addons/character_dna/components/body.py +++ b/src/addons/character_dna/components/body.py @@ -12,6 +12,7 @@ # local imports from .. import utilities +from ..fbx.reader import FbxAnimationClip from ..utilities import exclude_rig_instance_evaluation from .base import CharacterComponentBase @@ -28,6 +29,7 @@ def import_action( match_frame_rate: bool = True, prefix_instance_name: bool = True, prefix_component_name: bool = True, + clip: "FbxAnimationClip | None" = None, ): file_path = Path(file_path) @@ -39,6 +41,11 @@ def import_action( file_path=file_path, component="body", armature=self.body_rig_object, + round_sub_frames=round_sub_frames, + match_frame_rate=match_frame_rate, + prefix_instance_name=prefix_instance_name, + prefix_component_name=prefix_component_name, + clip=clip, # include animation only for body that are not driven by rig logic include_only_bones=[ b.name diff --git a/src/addons/character_dna/components/head.py b/src/addons/character_dna/components/head.py index 73490868..b191e0cd 100644 --- a/src/addons/character_dna/components/head.py +++ b/src/addons/character_dna/components/head.py @@ -17,6 +17,7 @@ FACE_BOARD_SWITCHES, REGION_VERTEX_GROUP_PREFIX, ) +from ..fbx.reader import FbxAnimationClip from ..rig_definition import HeadRigDefinition from ..utilities import exclude_rig_instance_evaluation from .base import CharacterComponentBase @@ -35,6 +36,7 @@ def import_action( match_frame_rate: bool = True, prefix_instance_name: bool = True, prefix_component_name: bool = True, + clip: "FbxAnimationClip | None" = None, ): file_path = Path(file_path) @@ -50,6 +52,7 @@ def import_action( match_frame_rate=match_frame_rate, prefix_instance_name=prefix_instance_name, prefix_component_name=prefix_component_name, + clip=clip, ) elif self.head_rig_object: utilities.import_action_from_fbx( @@ -61,6 +64,7 @@ def import_action( match_frame_rate=match_frame_rate, prefix_instance_name=prefix_instance_name, prefix_component_name=prefix_component_name, + clip=clip, ) def ingest(self, align: bool = True, constrain: bool = True) -> tuple[bool, str]: diff --git a/src/addons/character_dna/dna_io/calibrator.py b/src/addons/character_dna/dna_io/calibrator.py index 35215b4e..b1e97fb0 100644 --- a/src/addons/character_dna/dna_io/calibrator.py +++ b/src/addons/character_dna/dna_io/calibrator.py @@ -97,7 +97,7 @@ def calibrate_vertex_positions(self): head_to_body_edge_loop_mapping=head_to_body_edge_loop_mapping, ) - real_name = mesh_object.name.replace(f"{self._instance.name}_", "") + real_name = utilities.remove_instance_prefix(mesh_object.name, self._instance.name) logger.info(f'Calibrating "{real_name}" vertex positions...') mesh_index = mesh_index_lookup.get(real_name) diff --git a/src/addons/character_dna/dna_io/exporter.py b/src/addons/character_dna/dna_io/exporter.py index 0a5e5fe3..7d06577d 100644 --- a/src/addons/character_dna/dna_io/exporter.py +++ b/src/addons/character_dna/dna_io/exporter.py @@ -168,7 +168,7 @@ def initialize_scene_data(self): self._images.append((output_item.image_object, output_item.name)) # Sort the meshes by the order in the ORDER dictionary - mesh_objects.sort(key=lambda x: x.name.replace(f"{self._prefix}_", "")) + mesh_objects.sort(key=lambda x: utilities.remove_instance_prefix(x.name, self._prefix)) # Populate the LODs with the mesh objects and their indices mesh_index = 1 @@ -582,7 +582,7 @@ def export_meshes(self, bone_indices: list[int]): def _export_mesh(self, mesh_object: bpy.types.Object, mesh_index: int): """Write a single mesh's geometry (positions, faces, normals, uvs, vertex layouts), skin weights, and vertex colors into the DNA at ``mesh_index``.""" - real_name = mesh_object.name.replace(f"{self._prefix}_", "") + real_name = utilities.remove_instance_prefix(mesh_object.name, self._prefix) logger.info(f'Exporting mesh: "{mesh_object.name}" to DNA as "{real_name}"...') self._dna_writer.clearFaceVertexLayoutIndices(meshIndex=mesh_index) @@ -659,7 +659,7 @@ def export_shape_keys(self): for mesh_objects in self._export_lods.values(): for mesh_object, export_mesh_index in mesh_objects: - real_name = mesh_object.name.replace(f"{self._prefix}_", "") + real_name = utilities.remove_instance_prefix(mesh_object.name, self._prefix) source_mesh_index = source_mesh_index_by_name.get(real_name) if source_mesh_index is None: # A brand new mesh (e.g. custom teeth under a name not in the diff --git a/src/addons/character_dna/editors b/src/addons/character_dna/editors index 9ab3ce92..357bc175 160000 --- a/src/addons/character_dna/editors +++ b/src/addons/character_dna/editors @@ -1 +1 @@ -Subproject commit 9ab3ce92d2f34bb2021a7cb379bd7cffcbaff9b2 +Subproject commit 357bc175e50bbe5ddf767752bd2ec3a17b12ef3f diff --git a/src/addons/character_dna/fbx/__init__.py b/src/addons/character_dna/fbx/__init__.py new file mode 100644 index 00000000..9cabc1db --- /dev/null +++ b/src/addons/character_dna/fbx/__init__.py @@ -0,0 +1,14 @@ +"""FBX animation reading and fast Action writing. + +:mod:`.reader` and :mod:`.maths` are free of Blender imports so they can be +tested standalone; :mod:`.writer` is the Blender-facing half. +""" + +from .reader import FbxAnimationClip, load_fbx_animation, load_fbx_animation_buffer + + +__all__ = [ + "FbxAnimationClip", + "load_fbx_animation", + "load_fbx_animation_buffer", +] diff --git a/src/addons/character_dna/fbx/maths.py b/src/addons/character_dna/fbx/maths.py new file mode 100644 index 00000000..48463d09 --- /dev/null +++ b/src/addons/character_dna/fbx/maths.py @@ -0,0 +1,329 @@ +"""Vectorized quaternion and transform math for FBX animation ingestion. + +Conventions match the rest of the FBX pipeline: ``float64`` throughout and +w-first ``(w, x, y, z)`` quaternions. Every function is vectorized over leading +dimensions so whole animations can be processed without Python loops. + +This module must stay free of ``bpy``/``mathutils`` so it can be unit tested +without Blender. +""" + +import numpy as np + + +_EPSILON = 1e-12 + + +def quat_normalize(q: np.ndarray) -> np.ndarray: + """Normalize quaternions to unit length. + + Args: + q: Quaternions with shape ``(..., 4)``. + + Returns: + Unit quaternions with the same shape. Zero-length inputs become identity. + """ + q = np.asarray(q, dtype=np.float64) + norm = np.linalg.norm(q, axis=-1, keepdims=True) + out = np.where(norm > _EPSILON, q / np.maximum(norm, _EPSILON), 0.0) + out[..., 0] = np.where(norm[..., 0] > _EPSILON, out[..., 0], 1.0) + return out + + +def quat_conjugate(q: np.ndarray) -> np.ndarray: + """Return the conjugate, which is the inverse for unit quaternions. + + Args: + q: Quaternions with shape ``(..., 4)``. + + Returns: + Conjugated quaternions with the same shape. + """ + q = np.asarray(q, dtype=np.float64) + out = q.copy() + out[..., 1:] = -out[..., 1:] + return out + + +def quat_multiply(a: np.ndarray, b: np.ndarray) -> np.ndarray: + """Compose two rotations, applying ``b`` first and then ``a``. + + Args: + a: Quaternions broadcastable to ``(..., 4)``. + b: Quaternions broadcastable to ``(..., 4)``. + + Returns: + The product ``a ∘ b``. + """ + a = np.asarray(a, dtype=np.float64) + b = np.asarray(b, dtype=np.float64) + aw, ax, ay, az = a[..., 0], a[..., 1], a[..., 2], a[..., 3] + bw, bx, by, bz = b[..., 0], b[..., 1], b[..., 2], b[..., 3] + return np.stack( + [ + aw * bw - ax * bx - ay * by - az * bz, + aw * bx + ax * bw + ay * bz - az * by, + aw * by - ax * bz + ay * bw + az * bx, + aw * bz + ax * by - ay * bx + az * bw, + ], + axis=-1, + ) + + +def quat_rotate_vector(q: np.ndarray, v: np.ndarray) -> np.ndarray: + """Rotate vectors by unit quaternions (``v' = q v q*``). + + Args: + q: Unit quaternions broadcastable to ``(..., 4)``. + v: Vectors broadcastable to ``(..., 3)``. + + Returns: + Rotated vectors with shape ``(..., 3)``. + """ + q = np.asarray(q, dtype=np.float64) + v = np.asarray(v, dtype=np.float64) + qw = q[..., 0:1] + qv = q[..., 1:] + uv = np.cross(qv, v) + uuv = np.cross(qv, uv) + return v + 2.0 * (qw * uv + uuv) + + +def quat_from_matrix(m: np.ndarray) -> np.ndarray: + """Convert 3x3 rotation matrices to unit quaternions. + + Uses Shepperd's method, branching on the largest diagonal component for + numerical stability, vectorized over leading dimensions. + + Args: + m: Rotation matrices with shape ``(..., 3, 3)``. + + Returns: + Unit quaternions with shape ``(..., 4)``. + """ + m = np.asarray(m, dtype=np.float64) + m00, m01, m02 = m[..., 0, 0], m[..., 0, 1], m[..., 0, 2] + m10, m11, m12 = m[..., 1, 0], m[..., 1, 1], m[..., 1, 2] + m20, m21, m22 = m[..., 2, 0], m[..., 2, 1], m[..., 2, 2] + + trace = m00 + m11 + m22 + + q_w = np.stack([1.0 + trace, m21 - m12, m02 - m20, m10 - m01], axis=-1) + q_x = np.stack([m21 - m12, 1.0 + m00 - m11 - m22, m01 + m10, m02 + m20], axis=-1) + q_y = np.stack([m02 - m20, m01 + m10, 1.0 - m00 + m11 - m22, m12 + m21], axis=-1) + q_z = np.stack([m10 - m01, m02 + m20, m12 + m21, 1.0 - m00 - m11 + m22], axis=-1) + + choice = np.argmax(np.stack([trace, m00, m11, m22], axis=-1), axis=-1) + trace_positive = trace > 0.0 + + q = np.where( + trace_positive[..., None], + q_w, + np.where( + (choice == 1)[..., None], + q_x, + np.where((choice == 2)[..., None], q_y, q_z), + ), + ) + return quat_normalize(q) + + +def quat_to_euler(q: np.ndarray, order: str = "XYZ") -> np.ndarray: + """Convert unit quaternions to intrinsic Euler angles in radians. + + Only the rotation orders Blender pose bones can use are supported. The + result matches ``mathutils.Quaternion.to_euler(order)``. + + Args: + q: Unit quaternions with shape ``(..., 4)``. + order: One of ``"XYZ"``, ``"XZY"``, ``"YXZ"``, ``"YZX"``, ``"ZXY"``, ``"ZYX"``. + + Returns: + Euler angles with shape ``(..., 3)``, always ordered ``(x, y, z)`` + regardless of ``order``. + + Raises: + ValueError: If ``order`` is not a supported rotation order. + """ + matrix = quat_to_matrix(q) + return matrix_to_euler(matrix, order) + + +def quat_to_matrix(q: np.ndarray) -> np.ndarray: + """Convert unit quaternions to 3x3 rotation matrices. + + Args: + q: Unit quaternions with shape ``(..., 4)``. + + Returns: + Rotation matrices with shape ``(..., 3, 3)``. + """ + q = quat_normalize(q) + w, x, y, z = q[..., 0], q[..., 1], q[..., 2], q[..., 3] + xx, yy, zz = x * x, y * y, z * z + xy, xz, yz = x * y, x * z, y * z + wx, wy, wz = w * x, w * y, w * z + + return np.stack( + [ + np.stack([1.0 - 2.0 * (yy + zz), 2.0 * (xy - wz), 2.0 * (xz + wy)], axis=-1), + np.stack([2.0 * (xy + wz), 1.0 - 2.0 * (xx + zz), 2.0 * (yz - wx)], axis=-1), + np.stack([2.0 * (xz - wy), 2.0 * (yz + wx), 1.0 - 2.0 * (xx + yy)], axis=-1), + ], + axis=-2, + ) + + +# Axis letter to index. Blender applies an order like "XYZ" as X first, then Y, +# then Z, so the matrix is ``R_k @ R_j @ R_i``. +_AXIS_INDEX = {"X": 0, "Y": 1, "Z": 2} +_EULER_ORDERS = ("XYZ", "XZY", "YXZ", "YZX", "ZXY", "ZYX") + + +def matrix_to_euler(m: np.ndarray, order: str = "XYZ") -> np.ndarray: + """Extract Euler angles from 3x3 rotation matrices, matching Blender. + + Every rotation has two Euler representations. Blender returns whichever has + the smaller total absolute angle, and this reproduces that choice so the + values match ``mathutils.Quaternion.to_euler``. + + Args: + m: Rotation matrices with shape ``(..., 3, 3)``. + order: One of the six orders Blender supports. + + Returns: + Angles with shape ``(..., 3)`` ordered ``(x, y, z)``. + + Raises: + ValueError: If ``order`` is not a supported rotation order. + """ + if order not in _EULER_ORDERS: + raise ValueError(f"Unsupported rotation order: {order!r}") + + m = np.asarray(m, dtype=np.float64) + i = _AXIS_INDEX[order[0]] + j = _AXIS_INDEX[order[1]] + k = _AXIS_INDEX[order[2]] + # +1 for the cyclic orders (XYZ, YZX, ZXY), -1 for the rest. + parity = 1.0 if (j - i) % 3 == 1 else -1.0 + + sin_j = np.clip(-parity * m[..., k, i], -1.0, 1.0) + cos_j = np.sqrt(np.clip(1.0 - sin_j * sin_j, 0.0, 1.0)) + + numerator_i = parity * m[..., k, j] + denominator_i = m[..., k, k] + numerator_k = parity * m[..., j, i] + denominator_k = m[..., i, i] + + first = np.stack( + [ + np.arctan2(numerator_i, denominator_i), + np.arctan2(sin_j, cos_j), + np.arctan2(numerator_k, denominator_k), + ], + axis=-1, + ) + second = np.stack( + [ + np.arctan2(-numerator_i, -denominator_i), + np.arctan2(sin_j, -cos_j), + np.arctan2(-numerator_k, -denominator_k), + ], + axis=-1, + ) + + # At gimbal lock the first and last angles are no longer separable, so all of + # the rotation is folded into the first one. + gimbal = cos_j < 1e-7 + locked = np.stack( + [ + np.arctan2(-parity * m[..., j, k], m[..., j, j]), + np.arctan2(sin_j, cos_j), + np.zeros_like(sin_j), + ], + axis=-1, + ) + + use_second = np.sum(np.abs(second), axis=-1) < np.sum(np.abs(first), axis=-1) + chosen = np.where(use_second[..., None], second, first) + chosen = np.where(gimbal[..., None], locked, chosen) + + out = np.empty((*m.shape[:-2], 3), dtype=np.float64) + out[..., i] = chosen[..., 0] + out[..., j] = chosen[..., 1] + out[..., k] = chosen[..., 2] + return out + + +def ensure_continuity(quats: np.ndarray) -> np.ndarray: + """Flip quaternion signs so consecutive frames stay on the same hemisphere. + + Prevents interpolation artifacts once the values are written to FCurves. + Operates along axis 0, which is the frame axis. + + Args: + quats: Quaternion tracks with shape ``(frames, ..., 4)``. + + Returns: + A sign-corrected copy with the same shape. + """ + quats = np.array(quats, dtype=np.float64, copy=True) + for frame in range(1, quats.shape[0]): + dot = np.sum(quats[frame] * quats[frame - 1], axis=-1, keepdims=True) + quats[frame] = np.where(dot < 0.0, -quats[frame], quats[frame]) + return quats + + +def transform_inverse(rotation: np.ndarray, translation: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Invert rigid transforms expressed as a quaternion and a translation. + + Args: + rotation: Unit quaternions with shape ``(..., 4)``. + translation: Translations with shape ``(..., 3)``. + + Returns: + Tuple of inverted ``(rotation, translation)``. + """ + inverse_rotation = quat_conjugate(rotation) + return inverse_rotation, -quat_rotate_vector(inverse_rotation, translation) + + +def transform_multiply( + rotation_a: np.ndarray, + translation_a: np.ndarray, + rotation_b: np.ndarray, + translation_b: np.ndarray, +) -> tuple[np.ndarray, np.ndarray]: + """Compose two rigid transforms, applying ``b`` first and then ``a``. + + Args: + rotation_a: Unit quaternions broadcastable to ``(..., 4)``. + translation_a: Translations broadcastable to ``(..., 3)``. + rotation_b: Unit quaternions broadcastable to ``(..., 4)``. + translation_b: Translations broadcastable to ``(..., 3)``. + + Returns: + Tuple of the composed ``(rotation, translation)``. + """ + rotation = quat_multiply(rotation_a, rotation_b) + translation = translation_a + quat_rotate_vector(rotation_a, translation_b) + return rotation, translation + + +def decompose_matrix(matrix: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Split 4x4 column-vector matrices into a rotation quaternion and translation. + + Scale is stripped by normalizing the basis columns. + + Args: + matrix: Matrices with shape ``(..., 4, 4)``. + + Returns: + Tuple of w-first quaternion ``(..., 4)`` and translation ``(..., 3)``. + """ + matrix = np.asarray(matrix, dtype=np.float64) + translation = matrix[..., :3, 3] + basis = matrix[..., :3, :3] + norms = np.linalg.norm(basis, axis=-2, keepdims=True) + basis = basis / np.where(norms > _EPSILON, norms, 1.0) + return quat_from_matrix(basis), translation diff --git a/src/addons/character_dna/fbx/reader.py b/src/addons/character_dna/fbx/reader.py new file mode 100644 index 00000000..0dccad93 --- /dev/null +++ b/src/addons/character_dna/fbx/reader.py @@ -0,0 +1,351 @@ +"""FBX animation ingestion through the ufbx bindings. + +Only the node hierarchy and its animation are read; geometry and embedded media +are skipped, which is what makes this dramatically faster than round-tripping +through ``bpy.ops.import_scene.fbx``. + +The data is returned in the file's own coordinate space and units. Converting it +onto a Blender armature is the writer's job, because that conversion needs the +target rig's rest pose. + +Requires the ``pyufbx`` package (imported as ``ufbx``). The import is deferred so +this module can be imported without the bindings present. +""" + +import logging + +from dataclasses import dataclass +from functools import cached_property +from pathlib import Path +from types import ModuleType +from typing import Any + +import numpy as np + +from .maths import decompose_matrix, ensure_continuity, quat_normalize + + +logger = logging.getLogger(__name__) + +DEFAULT_FRAME_RATE = 30.0 + +# ufbx CoordinateAxis values. +AXIS_NAMES = { + 0: "X", + 1: "-X", + 2: "Y", + 3: "-Y", + 4: "Z", + 5: "-Z", +} + + +@dataclass(frozen=True) +class FbxAnimationClip: + """A node hierarchy and its baked animation, in the FBX file's own space. + + Rotations are w-first ``(w, x, y, z)`` and every transform is + parent-relative unless the name says otherwise. Animation arrays are + frame-major so a whole channel can be sliced without a copy. + + Attributes: + node_names: Node names in hierarchy order (breadth first from the root). + parent_indices: ``(N,)`` index of each node's parent, ``-1`` for roots. + rest_rotations: ``(N, 4)`` parent-relative rest rotations. + rest_translations: ``(N, 3)`` parent-relative rest translations. + rest_world_rotations: ``(N, 4)`` rest rotations in file world space. + rest_world_translations: ``(N, 3)`` rest translations in file world space. + rotations: ``(F, N, 4)`` parent-relative animated rotations. + translations: ``(F, N, 3)`` parent-relative animated translations. + animated_node_names: Names of nodes that actually carried keys. + frame_rate: Rate the animation was sampled at, in frames per second. + file_frame_rate: The frame rate declared by the file. + unit_meters: Size of one file unit in meters, e.g. ``0.01`` for centimeters. + up_axis: The file's up axis, such as ``"Y"`` or ``"-Z"``. + take_name: Name of the animation stack that was baked. + """ + + node_names: tuple[str, ...] + parent_indices: np.ndarray + rest_rotations: np.ndarray + rest_translations: np.ndarray + rest_world_rotations: np.ndarray + rest_world_translations: np.ndarray + rotations: np.ndarray + translations: np.ndarray + animated_node_names: frozenset[str] + frame_rate: float + file_frame_rate: float + unit_meters: float + up_axis: str + take_name: str + + @property + def num_nodes(self) -> int: + """Number of nodes in the hierarchy.""" + return len(self.node_names) + + @property + def num_frames(self) -> int: + """Number of sampled frames.""" + return int(self.rotations.shape[0]) + + @cached_property + def node_indices(self) -> dict[str, int]: + """Map of node name to its index, keeping the first of any duplicates.""" + indices: dict[str, int] = {} + for index, name in enumerate(self.node_names): + indices.setdefault(name, index) + return indices + + +def _require_ufbx() -> ModuleType: + """Import and return the ufbx module. + + Returns: + The imported ``ufbx`` module. + + Raises: + ImportError: If the bindings are not installed. + """ + try: + import ufbx + except ImportError as error: + raise ImportError( + "The 'pyufbx' package is required for FBX animation import. It ships with the " + "Character DNA extension; if you are running from source, install it with " + "'uv sync' from a checkout that has ../ufbx-python next to it." + ) from error + return ufbx + + +def _interpolate_track( + key_times: np.ndarray, + key_values: np.ndarray, + sample_times: np.ndarray, + rest_value: np.ndarray, +) -> np.ndarray: + """Sample a keyed track at uniform times, one component at a time. + + Args: + key_times: ``(K,)`` strictly increasing key times in seconds. + key_values: ``(K, C)`` key values. + sample_times: ``(F,)`` output sample times in seconds. + rest_value: ``(C,)`` fallback used when the track has no keys. + + Returns: + ``(F, C)`` values. Samples outside the keyed range clamp to the first + or last key. + """ + num_frames = sample_times.shape[0] + num_components = rest_value.shape[0] + if key_times.shape[0] == 0: + return np.broadcast_to(rest_value, (num_frames, num_components)).copy() + + out = np.empty((num_frames, num_components), dtype=np.float64) + for component in range(num_components): + out[:, component] = np.interp(sample_times, key_times, key_values[:, component]) + return out + + +def _order_nodes(scene: Any) -> list[Any]: + """Return every non-root node in breadth-first hierarchy order. + + Args: + scene: A loaded ufbx scene. + + Returns: + The ordered node list, guaranteeing parents precede their children. + """ + ordered: list[Any] = [] + queue = [node for node in scene.nodes if node.parent is not None and node.parent.is_root] + while queue: + node = queue.pop(0) + ordered.append(node) + queue.extend(node.children) + return ordered + + +def _select_baked_anim(scene: Any, frame_rate: float) -> tuple[Any, str]: + """Bake the animation stack that carries the most animation. + + Files routinely contain empty leftover stacks next to the real take, so the + stack that animates the most nodes wins, with duration breaking ties. + + Args: + scene: A loaded ufbx scene. + frame_rate: Resample rate in frames per second. + + Returns: + Tuple of the baked animation and the name of the stack it came from. + """ + baked = scene.bake_anim(resample_rate=frame_rate, trim_start_time=True) + stack_name = "" + + anim_stacks = scene.anim_stacks + if anim_stacks: + stack_name = anim_stacks[0].name + for stack in anim_stacks: + candidate = scene.bake_anim(anim=stack.anim, resample_rate=frame_rate, trim_start_time=True) + better_coverage = len(candidate.nodes) > len(baked.nodes) + longer_tie_break = ( + len(candidate.nodes) == len(baked.nodes) > 0 and candidate.playback_duration > baked.playback_duration + ) + if better_coverage or longer_tie_break: + baked = candidate + stack_name = stack.name + + return baked, stack_name + + +def load_fbx_animation(file_path: str | Path, frame_rate: float | None = None) -> FbxAnimationClip: + """Load a node hierarchy and its animation from an FBX file. + + Args: + file_path: Path to the FBX file. + frame_rate: Rate to resample the animation at. Defaults to the file's + own rate, falling back to 30 fps when the file does not declare one. + + Returns: + The loaded clip, in the file's native space and units. + + Raises: + FileNotFoundError: If the file does not exist. + ValueError: If the file contains no node hierarchy. + ImportError: If the ufbx bindings are not installed. + """ + file_path = Path(file_path) + if not file_path.exists(): + raise FileNotFoundError(f"FBX file not found: {file_path}") + + ufbx = _require_ufbx() + scene = ufbx.load_file(str(file_path), ignore_geometry=True, ignore_embedded=True) + try: + return _build_clip(scene, frame_rate) + finally: + scene.close() + + +def load_fbx_animation_buffer( + data: bytes | bytearray | memoryview, + frame_rate: float | None = None, +) -> FbxAnimationClip: + """Load a node hierarchy and its animation from an in-memory FBX buffer. + + Args: + data: The raw contents of an FBX file. + frame_rate: Rate to resample the animation at, as in + :func:`load_fbx_animation`. + + Returns: + The loaded clip, in the file's native space and units. + + Raises: + ValueError: If the buffer contains no node hierarchy. + ImportError: If the ufbx bindings are not installed. + """ + ufbx = _require_ufbx() + scene = ufbx.load_memory(data, ignore_geometry=True, ignore_embedded=True) + try: + return _build_clip(scene, frame_rate) + finally: + scene.close() + + +def _build_clip(scene: Any, frame_rate: float | None) -> FbxAnimationClip: + """Convert a loaded ufbx scene into an :class:`FbxAnimationClip`. + + Args: + scene: A loaded ufbx scene. + frame_rate: Requested resample rate, or ``None`` to use the file's rate. + + Returns: + The converted clip. + + Raises: + ValueError: If the scene has no node hierarchy. + """ + file_frame_rate = float(scene.settings.frames_per_second or 0.0) + if frame_rate is None: + frame_rate = file_frame_rate if file_frame_rate > 0.0 else DEFAULT_FRAME_RATE + + ordered = _order_nodes(scene) + if not ordered: + raise ValueError("FBX file contains no node hierarchy to import.") + + num_nodes = len(ordered) + typed_id_to_index = {node.typed_id: index for index, node in enumerate(ordered)} + + parent_indices = np.empty(num_nodes, dtype=np.int64) + local_matrices = np.empty((num_nodes, 4, 4), dtype=np.float64) + world_matrices = np.empty((num_nodes, 4, 4), dtype=np.float64) + + for index, node in enumerate(ordered): + parent = node.parent + parent_indices[index] = ( + typed_id_to_index.get(parent.typed_id, -1) if parent is not None and not parent.is_root else -1 + ) + # The binding fills column-major data into a row-major buffer, so what + # comes back is the transpose of the mathematical matrix. + local_matrices[index] = np.asarray(node.local_transform, dtype=np.float64).T + world_matrices[index] = np.asarray(node.world_transform, dtype=np.float64).T + + rest_rotations, rest_translations = decompose_matrix(local_matrices) + rest_world_rotations, rest_world_translations = decompose_matrix(world_matrices) + + baked, take_name = _select_baked_anim(scene, frame_rate) + + duration = max(baked.playback_duration, baked.key_time_max, 0.0) + num_frames = max(round(duration * frame_rate) + 1, 1) + sample_times = np.arange(num_frames, dtype=np.float64) / frame_rate + + rotations = np.broadcast_to(rest_rotations[None], (num_frames, num_nodes, 4)).copy() + translations = np.broadcast_to(rest_translations[None], (num_frames, num_nodes, 3)).copy() + + animated_node_names: set[str] = set() + for baked_node in baked.nodes: + node_index = typed_id_to_index.get(baked_node.typed_id) + if node_index is None: + continue + + has_rotation = len(baked_node.rotation_times) > 0 + has_translation = len(baked_node.translation_times) > 0 + if has_rotation or has_translation: + animated_node_names.add(ordered[node_index].name) + + if has_rotation: + # ufbx stores (x, y, z, w); roll it to the w-first convention, then + # remove hemisphere flips before interpolating componentwise. + keys = np.roll(np.asarray(baked_node.rotation_values, dtype=np.float64), 1, axis=1) + sampled = _interpolate_track( + np.asarray(baked_node.rotation_times, dtype=np.float64), + ensure_continuity(keys), + sample_times, + rest_rotations[node_index], + ) + rotations[:, node_index] = quat_normalize(sampled) + + if has_translation: + translations[:, node_index] = _interpolate_track( + np.asarray(baked_node.translation_times, dtype=np.float64), + np.asarray(baked_node.translation_values, dtype=np.float64), + sample_times, + rest_translations[node_index], + ) + + return FbxAnimationClip( + node_names=tuple(node.name for node in ordered), + parent_indices=parent_indices, + rest_rotations=rest_rotations, + rest_translations=rest_translations, + rest_world_rotations=rest_world_rotations, + rest_world_translations=rest_world_translations, + rotations=rotations, + translations=translations, + animated_node_names=frozenset(animated_node_names), + frame_rate=float(frame_rate), + file_frame_rate=file_frame_rate, + unit_meters=float(scene.settings.unit_meters or 1.0), + up_axis=AXIS_NAMES.get(int(scene.settings.axes.up), "Y"), + take_name=take_name, + ) diff --git a/src/addons/character_dna/fbx/writer.py b/src/addons/character_dna/fbx/writer.py new file mode 100644 index 00000000..9a4d88b4 --- /dev/null +++ b/src/addons/character_dna/fbx/writer.py @@ -0,0 +1,321 @@ +"""Fast Action writing for animation loaded by :mod:`.reader`. + +Values are converted straight into Blender pose-bone basis space and written +with ``foreach_set``, so an entire channel becomes a single bulk call instead of +one ``keyframe_insert`` per frame. + +Because pose basis values are rest-relative, any constant change of basis +between the FBX file and Blender cancels out of both the rotation and the +translation, so no axis conversion is applied here. +""" + +import logging + +import bpy +import numpy as np + +from ..constants import IS_BLENDER_5 +from .maths import ( + ensure_continuity, + quat_conjugate, + quat_multiply, + quat_rotate_vector, + quat_to_euler, +) +from .reader import FbxAnimationClip + + +if IS_BLENDER_5: + from bpy_extras import anim_utils +else: + anim_utils = None + +logger = logging.getLogger(__name__) + +# Blender scenes are 1 based, and Blender's own FBX importer starts takes on frame 1. +FIRST_FRAME = 1 + +EULER_ORDERS = frozenset({"XYZ", "XZY", "YXZ", "YZX", "ZXY", "ZYX"}) + +_ZERO_TOLERANCE = 1e-9 + + +def ensure_action(name: str, replace: bool = True, id_root: str = "OBJECT") -> bpy.types.Action: + """Create an Action, optionally replacing an existing one with the same name. + + Args: + name: Name for the Action. + replace: Remove an existing Action of this name first. + id_root: Data-block type the Action animates. Only used on Blender 4.5, + where assigning an Action does not set it. + + Returns: + The new Action. + """ + existing = bpy.data.actions.get(name) + if existing and replace: + bpy.data.actions.remove(existing) + + action = bpy.data.actions.new(name=name) + if not anim_utils: + action.id_root = id_root # pyright: ignore[reportAttributeAccessIssue] + return action + + +def get_channel_container( + action: bpy.types.Action, + id_owner: bpy.types.Object, + slot_type: str = "OBJECT", +) -> object | None: + """Return the object that owns the Action's fcurves. + + Blender 4.5 stores fcurves on the Action, Blender 5.x stores them on a + channel bag belonging to a slot. + + Args: + action: The Action to write into. + id_owner: The data-block the Action will be assigned to. + slot_type: Slot type identifier used on Blender 5.x. + + Returns: + The Action itself on Blender 4.5, its channel bag on Blender 5.x, or + ``None`` if the channel bag could not be created. + """ + if not anim_utils: + return action + + if len(action.slots) == 0: + action.slots.new(slot_type, name=id_owner.name) + return anim_utils.action_ensure_channelbag_for_slot(action, action.slots[0]) + + +def assign_action(id_owner: bpy.types.Object, action: bpy.types.Action) -> None: + """Assign an Action, and its first slot, to a data-block. + + Args: + id_owner: The object to assign the Action to. + action: The Action to assign. + + Raises: + RuntimeError: If animation data could not be created. + """ + if not id_owner.animation_data: + id_owner.animation_data_create() + if not id_owner.animation_data: + raise RuntimeError(f"Failed to create animation data for {id_owner.name}.") + + id_owner.animation_data.action = action + if action.slots: + id_owner.animation_data.action_slot = action.slots[0] + + +def write_bulk_fcurves( + container: object, + data_path: str, + values: np.ndarray, + frames: np.ndarray, +) -> None: + """Create one FCurve per channel and bulk insert every keyframe. + + Args: + container: An Action or channel bag from :func:`get_channel_container`. + data_path: The RNA path to animate, without the array index. + values: ``(F, C)`` values, one column per channel. + frames: ``(F,)`` frame numbers. + """ + num_frames, num_channels = values.shape + coordinates = np.empty(num_frames * 2, dtype=np.float32) + coordinates[0::2] = frames + + for channel in range(num_channels): + fcurve = container.fcurves.new(data_path=data_path, index=channel) # type: ignore[attr-defined] + fcurve.keyframe_points.add(count=num_frames) + coordinates[1::2] = values[:, channel] + fcurve.keyframe_points.foreach_set("co", coordinates) + fcurve.update() + + +def _write_rotation( + container: object, + pose_bone: bpy.types.PoseBone, + rotations: np.ndarray, + frames: np.ndarray, +) -> None: + """Write a rotation track in whatever rotation mode the pose bone uses. + + Args: + container: An Action or channel bag. + pose_bone: The pose bone being animated. + rotations: ``(F, 4)`` w-first basis quaternions. + frames: ``(F,)`` frame numbers. + """ + data_path = f'pose.bones["{pose_bone.name}"]' + mode = pose_bone.rotation_mode + + if mode in EULER_ORDERS: + euler = quat_to_euler(rotations, mode) + # Keep angles continuous so the curve does not jump by a full turn. + euler = np.unwrap(euler, axis=0) + write_bulk_fcurves(container, f"{data_path}.rotation_euler", euler.astype(np.float32), frames) + return + + if mode == "AXIS_ANGLE": + pose_bone.rotation_mode = "QUATERNION" + write_bulk_fcurves(container, f"{data_path}.rotation_quaternion", rotations.astype(np.float32), frames) + + +def _is_effectively_zero(values: np.ndarray) -> bool: + """Return ``True`` when every value is within floating point noise of zero.""" + return bool(np.all(np.abs(values) <= _ZERO_TOLERANCE)) + + +def _basis_rotations(rest_rotation: np.ndarray, rotations: np.ndarray) -> np.ndarray: + """Convert local rotations into rest-relative basis quaternions. + + Args: + rest_rotation: ``(4,)`` rest rotation of the node. + rotations: ``(F, 4)`` animated local rotations. + + Returns: + ``(F, 4)`` continuous basis quaternions, starting in the ``w >= 0`` + hemisphere so the track reads as a deviation from rest rather than a + full turn away from it. + """ + basis = ensure_continuity(quat_multiply(quat_conjugate(rest_rotation), rotations)) + if basis[0, 0] < 0.0: + basis = -basis + return basis + + +def frame_range(clip: FbxAnimationClip) -> np.ndarray: + """Return the Blender frame numbers for a clip's samples. + + Args: + clip: The clip being written. + + Returns: + ``(F,)`` frame numbers starting at :data:`FIRST_FRAME`. + """ + return np.arange(clip.num_frames, dtype=np.float64) + FIRST_FRAME + + +def write_skeleton_animation( + clip: FbxAnimationClip, + armature: bpy.types.Object, + action: bpy.types.Action, + include_bones: set[str] | None = None, +) -> list[str]: + """Write a skeletal clip onto an armature's pose bones. + + Node transforms are converted from FBX parent-relative space into Blender + pose basis space: ``rotation = rest⁻¹ ∘ local`` and + ``location = rest⁻¹ · (local - rest)`` scaled to meters. Location channels + that never leave the rest pose are skipped. + + Args: + clip: The loaded animation. + armature: The target armature object. + action: The Action to write into. + include_bones: Only write these bone names when given. + + Returns: + The names of the bones that were written. + """ + if not armature.pose: + return [] + + container = get_channel_container(action, armature) + if container is None: + return [] + + frames = frame_range(clip) + node_indices = clip.node_indices + written: list[str] = [] + + for pose_bone in armature.pose.bones: + name = pose_bone.name + if include_bones is not None and name not in include_bones: + continue + + node_index = node_indices.get(name) + if node_index is None: + continue + + rest_rotation = clip.rest_rotations[node_index] + rest_translation = clip.rest_translations[node_index] + inverse_rest = quat_conjugate(rest_rotation) + + _write_rotation(container, pose_bone, _basis_rotations(rest_rotation, clip.rotations[:, node_index]), frames) + + offsets = clip.translations[:, node_index] - rest_translation + basis_locations = quat_rotate_vector(inverse_rest, offsets) * clip.unit_meters + if not _is_effectively_zero(basis_locations): + write_bulk_fcurves( + container, + f'pose.bones["{name}"].location', + basis_locations.astype(np.float32), + frames, + ) + + written.append(name) + + return written + + +def write_face_board_animation( + clip: FbxAnimationClip, + armature: bpy.types.Object, + action: bpy.types.Action, + exclude_bones: frozenset[str] = frozenset(), +) -> list[str]: + """Write a face board clip onto the face board armature's control bones. + + Face board controls are driven by raw translation in the board's own space, + so values are copied without unit conversion. Rotation channels are written + only when a control actually rotates. + + Args: + clip: The loaded animation. + armature: The face board armature object. + action: The Action to write into. + exclude_bones: Control names that must not be animated. + + Returns: + The names of the controls that were written. + """ + if not armature.pose: + return [] + + container = get_channel_container(action, armature) + if container is None: + return [] + + frames = frame_range(clip) + node_indices = clip.node_indices + written: list[str] = [] + + for pose_bone in armature.pose.bones: + name = pose_bone.name + if name in exclude_bones: + continue + + node_index = node_indices.get(name) + if node_index is None: + continue + + locations = clip.translations[:, node_index] + write_bulk_fcurves( + container, + f'pose.bones["{name}"].location', + locations.astype(np.float32), + frames, + ) + + rest_rotation = clip.rest_rotations[node_index] + basis_rotations = _basis_rotations(rest_rotation, clip.rotations[:, node_index]) + if not _is_effectively_zero(basis_rotations[:, 1:]): + _write_rotation(container, pose_bone, basis_rotations, frames) + + written.append(name) + + return written diff --git a/src/addons/character_dna/operators.py b/src/addons/character_dna/operators.py index c5faa8d9..c2f7e58f 100644 --- a/src/addons/character_dna/operators.py +++ b/src/addons/character_dna/operators.py @@ -20,9 +20,11 @@ ToolInfo, ) from .dna_io import DNACalibrator, DNAExporter +from .fbx.reader import FbxAnimationClip from .properties import BlendFileCharacterCollection, CharacterImportProperties from .typing import * # noqa: F403 from .ui import callbacks, importer +from .validators import ValidationReport logger = logging.getLogger(__name__) @@ -36,6 +38,17 @@ class GenericUIListOperator: active_index: bpy.props.IntProperty() # pyright: ignore[reportInvalidTypeForm] + def _resolve_active_index(self, collection: "bpy.types.bpy_prop_collection") -> int | None: + """Clamp ``active_index`` into ``collection``, or ``None`` when it is empty. + + The index is passed in as an operator property, so it can be stale or bogus when the + operator is run from a keymap, the search menu, or a script rather than the list's buttons. + """ + count = len(collection) + if count == 0: + return None + return max(0, min(self.active_index, count - 1)) + class GenericProgressQueueOperator(bpy.types.Operator): """ @@ -263,7 +276,14 @@ def execute(self, context: "Context") -> set[str]: # noqa: PLR0912, PLR0915 head_rig_object=instance.head_rig, face_board_object=instance.face_board, ) - if self.operation_type != "LINK": + if self.operation_type == "LINK": + if collection: + utilities.group_face_board_with_linked_collection( + face_board=instance.face_board, + linked_collection=collection, + collection_name=collection_name, + ) + else: utilities.move_to_collection( scene_objects=[instance.face_board], collection_name=collection_name, exclusively=True ) @@ -287,6 +307,11 @@ def execute(self, context: "Context") -> set[str]: # noqa: PLR0912, PLR0915 class ImportAnimationBase(bpy.types.Operator): filename_ext = ".fbx" + # Set by each subclass: the python operator name under ``bpy.ops.character_dna``. + # ``bl_idname`` cannot be used because Blender replaces it with the RNA + # identifier once the class is registered. + import_operator_id = "" + filter_glob: bpy.props.StringProperty( default="*.fbx", options={"HIDDEN"}, @@ -329,41 +354,163 @@ class ImportAnimationBase(bpy.types.Operator): ), ) # pyright: ignore[reportInvalidTypeForm] + ignore_validation: bpy.props.BoolProperty( + default=False, + options={"HIDDEN", "SKIP_SAVE"}, + description="Import even when the file does not look like it belongs to this rig", + ) # pyright: ignore[reportInvalidTypeForm] + @property def settings_title(self) -> str: return "Animation Import Settings:" + def load_clip(self, file_path: Path) -> FbxAnimationClip | None: + """Read the FBX file, reporting any failure to the user. + + Args: + file_path: Path to the FBX file. + + Returns: + The loaded clip, or ``None`` when the file could not be read. + """ + try: + return utilities.load_animation_clip(file_path, match_frame_rate=self.match_frame_rate) + except (FileNotFoundError, ImportError, ValueError) as error: + self.report({"ERROR"}, str(error)) + return None + + def confirm_or_cancel(self, report: ValidationReport, file_path: Path, component_type: str) -> bool: + """Surface a validation report, asking the user whether to import anyway. + + Args: + report: The validation report for the loaded clip. + file_path: Path to the FBX file. + component_type: Component the animation is being imported onto. + + Returns: + ``True`` when the import should continue. + """ + for warning in report.warnings: + logger.warning(warning.message) + + if report.is_valid or self.ignore_validation: + return True + + if bpy.app.background: + # There is nobody to answer a dialog, so refuse rather than silently + # importing the wrong file. Scripts can pass ignore_validation=True. + self.report({"ERROR"}, report.summary()) + return False + + getattr(bpy.ops, ToolInfo.NAME).confirm_animation_import( + "INVOKE_DEFAULT", + operator_name=self.import_operator_id, + filepath=str(file_path), + component_type=component_type, + message=report.summary(), + round_sub_frames=self.round_sub_frames, + match_frame_rate=self.match_frame_rate, + prefix_instance_name=self.prefix_instance_name, + prefix_component_name=self.prefix_component_name, + ) + return False + + +class ConfirmAnimationImport(bpy.types.Operator): + """Ask whether to import an animation file that does not appear to match the target rig""" + + bl_idname = f"{ToolInfo.NAME}.confirm_animation_import" + bl_label = "This Might Be the Wrong Animation File" + + operator_name: bpy.props.StringProperty(default="") # pyright: ignore[reportInvalidTypeForm] + filepath: bpy.props.StringProperty(default="", subtype="FILE_PATH") # pyright: ignore[reportInvalidTypeForm] + component_type: bpy.props.StringProperty(default="body") # pyright: ignore[reportInvalidTypeForm] + message: bpy.props.StringProperty(default="") # pyright: ignore[reportInvalidTypeForm] + round_sub_frames: bpy.props.BoolProperty(default=True) # pyright: ignore[reportInvalidTypeForm] + match_frame_rate: bpy.props.BoolProperty(default=True) # pyright: ignore[reportInvalidTypeForm] + prefix_instance_name: bpy.props.BoolProperty(default=True) # pyright: ignore[reportInvalidTypeForm] + prefix_component_name: bpy.props.BoolProperty(default=True) # pyright: ignore[reportInvalidTypeForm] + + def execute(self, context: "Context") -> set[str]: + operator = getattr(getattr(bpy.ops, ToolInfo.NAME), self.operator_name, None) + if not operator: + self.report({"ERROR"}, f"Unknown import operator: {self.operator_name}") + return {"CANCELLED"} + + keywords = { + "filepath": self.filepath, + "round_sub_frames": self.round_sub_frames, + "match_frame_rate": self.match_frame_rate, + "prefix_instance_name": self.prefix_instance_name, + "prefix_component_name": self.prefix_component_name, + "ignore_validation": True, + } + if self.operator_name == "import_component_animation": + keywords["component_type"] = self.component_type + + return operator("EXEC_DEFAULT", **keywords) + + def invoke(self, context: "Context", event: bpy.types.Event) -> set[str] | None: + wm = context.window_manager + if not wm: + return None + return wm.invoke_props_dialog(self, confirm_text="Import Anyway", cancel_default=True, width=500) # type: ignore[return-value] + + def draw(self, context: "Context"): + if not self.layout: + return + + row = self.layout.row() + row.scale_y = 1.5 + row.label(text=f"{Path(self.filepath).name} might not be {self.component_type} animation.") + for line in self.message.split("\n"): + row = self.layout.row() + row.alert = True + row.label(text=line) + class ImportFaceBoardAnimation(ImportAnimationBase, importer.ImportAnimation): """Import an animation for the metahuman face board exported from an Unreal Engine Level Sequence""" bl_idname = f"{ToolInfo.NAME}.import_face_board_animation" bl_label = "Import" + import_operator_id = "import_face_board_animation" @property def settings_title(self) -> str: return "Face Board Animation Import Settings:" def execute(self, context: "Context") -> set[str]: - file_path = self.filepath # type: ignore[attr-defined] + file_path = Path(bpy.path.abspath(self.filepath)) # type: ignore[attr-defined] logger.info(f"Importing animation {file_path}") head = utilities.get_active_head() + if not head: + self.report({"ERROR"}, "No active head found") + return {"CANCELLED"} - if head: - if not head.face_board_object: - self.report({"ERROR"}, "No face board object found for the active head") - return {"CANCELLED"} + if not head.face_board_object: + self.report({"ERROR"}, "No face board object found for the active head") + return {"CANCELLED"} - head.import_action( - Path(file_path), - is_face_board=True, - round_sub_frames=self.round_sub_frames, - match_frame_rate=self.match_frame_rate, - prefix_instance_name=self.prefix_instance_name, - prefix_component_name=self.prefix_component_name, - ) - utilities.switch_to_pose_mode(head.face_board_object) - self.report({"INFO"}, f"Imported face board animation from {file_path}") + clip = self.load_clip(file_path) + if clip is None: + return {"CANCELLED"} + + report = utilities.validate_animation_clip(clip, head.face_board_object, "face board", is_face_board=True) + if not self.confirm_or_cancel(report, file_path, "face board"): + return {"CANCELLED"} + + head.import_action( + file_path, + is_face_board=True, + round_sub_frames=self.round_sub_frames, + match_frame_rate=self.match_frame_rate, + prefix_instance_name=self.prefix_instance_name, + prefix_component_name=self.prefix_component_name, + clip=clip, + ) + utilities.switch_to_pose_mode(head.face_board_object) + self.report({"INFO"}, f"Imported face board animation from {file_path}") return {"FINISHED"} @@ -372,6 +519,7 @@ class ImportComponentAnimation(ImportAnimationBase, importer.ImportAnimation): bl_idname = f"{ToolInfo.NAME}.import_component_animation" bl_label = "Import" + import_operator_id = "import_component_animation" component_type: bpy.props.StringProperty(default="body") # pyright: ignore[reportInvalidTypeForm] @@ -382,36 +530,73 @@ def settings_title(self) -> str: def execute(self, context: "Context") -> set[str]: file_path = Path(bpy.path.abspath(self.filepath)) # type: ignore[attr-defined] logger.info(f"Importing animation {file_path}") - if self.component_type == "head": - head = utilities.get_active_head() - if head: - head.import_action( - file_path, - is_face_board=False, - round_sub_frames=self.round_sub_frames, - match_frame_rate=self.match_frame_rate, - prefix_instance_name=self.prefix_instance_name, - prefix_component_name=self.prefix_component_name, - ) + if self.component_type == "head": + result = self._import_head(file_path) elif self.component_type == "body": - body = utilities.get_active_body() - if body: - if not body.body_rig_object: - self.report({"ERROR"}, "No body rig object found for the active body") - return {"CANCELLED"} + result = self._import_body(file_path) + else: + self.report({"ERROR"}, f"Unknown component type: {self.component_type}") + return {"CANCELLED"} - body.import_action( - file_path, - round_sub_frames=self.round_sub_frames, - match_frame_rate=self.match_frame_rate, - prefix_instance_name=self.prefix_instance_name, - prefix_component_name=self.prefix_component_name, - ) - utilities.switch_to_pose_mode(body.body_rig_object) + if result == {"FINISHED"}: + self.report({"INFO"}, f"Imported {self.component_type} animation from {file_path}") + return result + + def _import_head(self, file_path: Path) -> set[str]: + head = utilities.get_active_head() + if not head: + self.report({"ERROR"}, "No active head found") + return {"CANCELLED"} + if not head.head_rig_object: + self.report({"ERROR"}, "No head rig object found for the active head") + return {"CANCELLED"} + + clip = self.load_clip(file_path) + if clip is None: + return {"CANCELLED"} + + report = utilities.validate_animation_clip(clip, head.head_rig_object, "head") + if not self.confirm_or_cancel(report, file_path, "head"): + return {"CANCELLED"} + + head.import_action( + file_path, + is_face_board=False, + round_sub_frames=self.round_sub_frames, + match_frame_rate=self.match_frame_rate, + prefix_instance_name=self.prefix_instance_name, + prefix_component_name=self.prefix_component_name, + clip=clip, + ) + return {"FINISHED"} + + def _import_body(self, file_path: Path) -> set[str]: + body = utilities.get_active_body() + if not body: + self.report({"ERROR"}, "No active body found") + return {"CANCELLED"} + if not body.body_rig_object: + self.report({"ERROR"}, "No body rig object found for the active body") + return {"CANCELLED"} + + clip = self.load_clip(file_path) + if clip is None: + return {"CANCELLED"} - self.report({"INFO"}, f"Imported {self.component_type} animation from {file_path}") + report = utilities.validate_animation_clip(clip, body.body_rig_object, "body") + if not self.confirm_or_cancel(report, file_path, "body"): + return {"CANCELLED"} + body.import_action( + file_path, + round_sub_frames=self.round_sub_frames, + match_frame_rate=self.match_frame_rate, + prefix_instance_name=self.prefix_instance_name, + prefix_component_name=self.prefix_component_name, + clip=clip, + ) + utilities.switch_to_pose_mode(body.body_rig_object) return {"FINISHED"} @@ -1305,13 +1490,15 @@ def execute(self, context: "Context") -> set[str]: # noqa: PLR0912, PLR0915 break new_mesh_object = utilities.copy_mesh( mesh_object=mesh_object, - new_mesh_name=mesh_object.name.replace(instance.name, self.new_name), + new_mesh_name=utilities.replace_instance_prefix(mesh_object.name, instance.name, self.new_name), modifiers=False, materials=True, ) new_rig_object = utilities.copy_armature( armature_object=rig_object, - new_armature_name=rig_object.name.replace(instance.name, self.new_name), + new_armature_name=utilities.replace_instance_prefix( + rig_object.name, instance.name, self.new_name + ), ) # move the new rig to the right collection utilities.move_to_collection( @@ -1359,7 +1546,9 @@ def execute(self, context: "Context") -> set[str]: # noqa: PLR0912, PLR0915 new_extra_mesh_object = utilities.copy_mesh( mesh_object=item.scene_object, - new_mesh_name=item.scene_object.name.replace(instance.name, self.new_name), + new_mesh_name=utilities.replace_instance_prefix( + item.scene_object.name, instance.name, self.new_name + ), modifiers=False, materials=True, ) @@ -1657,9 +1846,13 @@ class UILIST_RIG_INSTANCE_OT_entry_remove(GenericUIListOperator, bpy.types.Opera def execute(self, context: "Context") -> set[str]: addon_scene_properties = utilities.get_addon_scene_properties(context) my_list = addon_scene_properties.rig_instance_list + active_index = self._resolve_active_index(my_list) + if active_index is None: + self.report({"WARNING"}, "There is no rig instance to remove") + return {"CANCELLED"} if self.delete_associated_data: - instance = addon_scene_properties.rig_instance_list[self.active_index] + instance = my_list[active_index] for component_type in ["body", "head"]: for item in getattr(instance.output, f"{component_type}_item_list"): if item.scene_object: @@ -1675,8 +1868,8 @@ def execute(self, context: "Context") -> set[str]: if collection: bpy.data.collections.remove(collection, do_unlink=True) - my_list.remove(self.active_index) - to_index = min(self.active_index, len(my_list) - 1) + my_list.remove(active_index) + to_index = min(active_index, len(my_list) - 1) addon_scene_properties.rig_instance_list_active_index = to_index # notify registered callbacks that a rig instance was removed from the list utilities.notify_rig_instances_changed() @@ -1684,8 +1877,13 @@ def execute(self, context: "Context") -> set[str]: def invoke(self, context: "Context", event: bpy.types.Event) -> set[str] | None: addon_scene_properties = utilities.get_addon_scene_properties(context) - instance = addon_scene_properties.rig_instance_list[self.active_index] - self.instance_name = instance.name if instance else "this instance" + my_list = addon_scene_properties.rig_instance_list + active_index = self._resolve_active_index(my_list) + if active_index is None: + self.report({"WARNING"}, "There is no rig instance to remove") + return {"CANCELLED"} + + self.instance_name = my_list[active_index].name return context.window_manager.invoke_props_dialog( # type: ignore[return-value] self, title=f"Remove: {self.instance_name}", confirm_text="Remove", width=400 ) @@ -1733,15 +1931,20 @@ class UILIST_RIG_INSTANCE_OT_entry_move(GenericUIListOperator, bpy.types.Operato def execute(self, context: "Context") -> set[str]: addon_scene_properties = utilities.get_addon_scene_properties(context) my_list = addon_scene_properties.rig_instance_list + active_index = self._resolve_active_index(my_list) + if active_index is None: + self.report({"WARNING"}, "There is no rig instance to move") + return {"CANCELLED"} + delta = { "DOWN": 1, "UP": -1, }[self.direction] - to_index = (self.active_index + delta) % len(my_list) + to_index = (active_index + delta) % len(my_list) - from_instance = addon_scene_properties.rig_instance_list[self.active_index] - to_instance = addon_scene_properties.rig_instance_list[to_index] + from_instance = my_list[active_index] + to_instance = my_list[to_index] if from_instance.body_rig and to_instance.body_rig: to_x = to_instance.body_rig.location.x @@ -1768,6 +1971,6 @@ def execute(self, context: "Context") -> set[str]: to_instance.face_board.location.x += from_x - to_x from_instance.face_board.location.x += to_x - from_x - my_list.move(self.active_index, to_index) + my_list.move(active_index, to_index) addon_scene_properties.rig_instance_list_active_index = to_index return {"FINISHED"} diff --git a/src/addons/character_dna/properties.py b/src/addons/character_dna/properties.py index 2179a0b9..22d411f2 100644 --- a/src/addons/character_dna/properties.py +++ b/src/addons/character_dna/properties.py @@ -328,14 +328,6 @@ class CharacterAddonProperties: next_metrics_consent_timestamp: bpy.props.FloatProperty(default=0.0) # pyright: ignore[reportInvalidTypeForm] extra_dna_folder_list: bpy.props.CollectionProperty(type=ExtraDnaFolder) # pyright: ignore[reportInvalidTypeForm] extra_dna_folder_list_active_index: bpy.props.IntProperty() # pyright: ignore[reportInvalidTypeForm] - batched_evaluations: bpy.props.BoolProperty( - name="Batched Evaluations", - default=True, - description=( - "Batch all rig evaluations onto the next draw call so all updates from a single scene change are " - "applied together. Disable this to evaluate each rig immediately when the scene's dependency graph updates" - ), - ) # pyright: ignore[reportInvalidTypeForm] show_pro_features: bpy.props.BoolProperty( name="Show Pro Features", default=True, diff --git a/src/addons/character_dna/release_notes.md b/src/addons/character_dna/release_notes.md index 0953ec3e..6641cf43 100644 --- a/src/addons/character_dna/release_notes.md +++ b/src/addons/character_dna/release_notes.md @@ -1,17 +1,14 @@ -## Major Changes - -* Initial implementation of the Behavior Viewer - ## Minor Changes -* Added Freeze option to Raw Editor and Shape Key Editor list filter -* Added Ghost indicator for shape keys that do not contain deltas. Also added toggle to filter them from the view. -* Added option to turn off dependency chain isolation in Shape Key Editor +* Changed animation import operators to use ufbx bindings for cleaner and more optimized importing of large animations. +* Removed batched dependency graph evaluations ## Patch Changes -* Fixed incomplete/opaque eye and saliva materials on Blender 4.x [#346](https://github.com/poly-hammer/character-dna-addon/issues/346) -* New Coordinate System policy is enforced on the DNA Reader to properly convert DNA's saved in other coordinate systems. +* Fixed rig instance index error +* Fixed the face board being left outside the character's collection when using Metahuman Append/Link > Link [#341](https://github.com/poly-hammer/character-dna-addon/issues/341) +* Fixed shape keys staying un-driven for the rest of the session when the head mesh was renamed or merged [#333](https://github.com/poly-hammer/character-dna-addon/issues/333) +* Fixed an error in the Migrate Legacy Data panel when an older Meta-Human DNA addon is still installed ## Tests Passing On diff --git a/src/addons/character_dna/rig_instance.py b/src/addons/character_dna/rig_instance.py index e8775eff..9fa84c41 100644 --- a/src/addons/character_dna/rig_instance.py +++ b/src/addons/character_dna/rig_instance.py @@ -13,7 +13,7 @@ # local imports from . import utilities -from .constants import FLOATING_POINT_PRECISION, IS_BLENDER_5, SCALE_FACTOR, SHAPE_KEY_NAME_MAX_LENGTH, ToolInfo +from .constants import FLOATING_POINT_PRECISION, IS_BLENDER_5, SCALE_FACTOR, SHAPE_KEY_NAME_MAX_LENGTH from .ui import callbacks from .typing import * # noqa: F403 @@ -25,59 +25,6 @@ logger = logging.getLogger(__name__) -# Deferred evaluation state: Handlers can run in a restricted context that blocks writes to -# content ID classes (pose bones,shape keys, materials). We collect pending evaluations in the -# handler and apply them via a zero-delay timer callback which runs in the main event loop with full write access. -# -# We store the rig instance *name* (its unique identifier) rather than the RigInstance -# PropertyGroup wrapper itself. An undo can free and reallocate the rig_instance_list -# collection items between the time the handler queues an evaluation and the time the timer -# fires, which would leave a dangling wrapper and crash when its RNA data is accessed. -_pending_evaluations: list[tuple[str, "ComponentType"]] = [] - - -def cancel_pending_evaluations() -> None: - """Cancel any queued deferred evaluation and unregister the timer. - - Called before operations that can invalidate the queued rig instances (e.g. undo or - loading a new file) so the timer never dereferences freed data. - """ - if bpy.app.timers.is_registered(_apply_deferred_evaluation): - bpy.app.timers.unregister(_apply_deferred_evaluation) - _pending_evaluations.clear() - - -def _apply_deferred_evaluation() -> None: - """Timer callback that applies pending rig evaluations in a writable context.""" - pending = list(_pending_evaluations) - _pending_evaluations.clear() - - addon_window_manager: "CharacterWindowManagerProperties | None" = getattr( # noqa: UP037 - bpy.context.window_manager, ToolInfo.NAME, None - ) - # Bail if the addon state is gone, an undo is in progress, or evaluation is disabled. - if not addon_window_manager or addon_window_manager.is_undoing: - return - if not addon_window_manager.evaluate_dependency_graph: - return - - scene_properties = getattr(bpy.context.scene, ToolInfo.NAME, None) - if not scene_properties: - return - - for name, component in pending: - # Re-resolve the instance by name; it may have been removed by an undo or edit. - instance = scene_properties.rig_instance_list.get(name) - if not instance: - continue - try: - instance.evaluate(component=component) - except ReferenceError: - # The underlying data was freed out from under us; skip it. - continue - except Exception as error: - logger.exception(f"Error evaluating rig instance '{name}': {error}") - def _compute_body_input_signature( instance: "RigInstance", dependency_graph: bpy.types.Depsgraph | None @@ -277,30 +224,14 @@ def rig_instance_listener(_: "Scene", dependency_graph: bpy.types.Depsgraph, is_ if not final_instance_updates: return - # When batched evaluations are disabled, apply the evaluations directly here instead of - # deferring them to the timer. This reduces latency (no batching jitter/lag) at the cost of - # potential instability while rendering, since handlers can run in a restricted context. - addon_preferences = utilities.get_addon_preferences() - if addon_preferences and not addon_preferences.batched_evaluations: - for instance, component in final_instance_updates: - try: - instance.evaluate(component=component) - except ReferenceError: - # The underlying data was freed out from under us; skip it. - continue - except Exception as error: - logger.exception(f"Error evaluating rig instance '{instance.name}': {error}") - return - - # Defer evaluation to a timer callback where Blender allows writing to ID data. - # Queue instances by name so an undo that reallocates the collection can't leave us - # holding a dangling PropertyGroup wrapper. - _pending_evaluations.clear() for instance, component in final_instance_updates: - _pending_evaluations.append((instance.name, component)) - - if _pending_evaluations and not bpy.app.timers.is_registered(_apply_deferred_evaluation): - bpy.app.timers.register(_apply_deferred_evaluation, first_interval=0) + try: + instance.evaluate(component=component) + except ReferenceError: + # The underlying data was freed out from under us; skip it. + continue + except Exception as error: + logger.exception(f"Error evaluating rig instance '{instance.name}': {error}") def frame_change_handler(scene: "Scene", dependency_graph: bpy.types.Depsgraph): @@ -308,16 +239,13 @@ def frame_change_handler(scene: "Scene", dependency_graph: bpy.types.Depsgraph): def stop_listening(): - # Cancel any pending deferred evaluation - cancel_pending_evaluations() - for handler in bpy.app.handlers.depsgraph_update_post: if handler.__name__ == rig_instance_listener.__name__: bpy.app.handlers.depsgraph_update_post.remove(handler) for handler in bpy.app.handlers.frame_change_post: if handler.__name__ == frame_change_handler.__name__: - bpy.app.handlers.frame_change_post.remove(handler) + bpy.app.handlers.frame_change_post.remove(handler) # pyright: ignore[reportArgumentType] def start_listening(): @@ -744,7 +672,7 @@ def head_shape_key_blocks(self) -> dict[int, list[bpy.types.ShapeKey]]: for target_index in range(self.head_dna_reader.getBlendShapeTargetCount(mesh_index)): channel_index = self.head_dna_reader.getBlendShapeChannelIndex(mesh_index, target_index) name = self.head_dna_reader.getBlendShapeChannelName(channel_index) - dna_mesh_name = mesh_object.name.replace(f"{self.name}_", "") + dna_mesh_name = utilities.remove_instance_prefix(mesh_object.name, self.name) shape_key_block_name = f"{dna_mesh_name}__{name}" shape_key_block = self.get_shape_key_block(mesh_index=mesh_index, name=shape_key_block_name) if shape_key_block: @@ -807,7 +735,7 @@ def head_shape_key_apply_plan( key_blocks = mesh_object.data.shape_keys.key_blocks # Resolve each namespaced block name to its collection index once. name_to_position = {block.name: position for position, block in enumerate(key_blocks)} - dna_mesh_name = mesh_object.name.replace(f"{self.name}_", "") + dna_mesh_name = utilities.remove_instance_prefix(mesh_object.name, self.name) positions: list[int] = [] channels: list[int] = [] @@ -835,6 +763,11 @@ def head_shape_key_apply_plan( ) ) + # An empty plan means no mesh resolved yet (e.g. a renamed or merged head mesh). + # Caching it would shadow a later correction for the rest of the session. + if not plan: + return plan + self.data[self.cache_key("head", "shape_key_apply_plan")] = plan return plan diff --git a/src/addons/character_dna/typing.py b/src/addons/character_dna/typing.py index ff33f929..5dba4235 100644 --- a/src/addons/character_dna/typing.py +++ b/src/addons/character_dna/typing.py @@ -123,7 +123,6 @@ class CharacterAddonPreferences(CharacterAddonProperties, bpy.types.AddonPrefere bl_idname: str metrics_collection: bool - batched_evaluations: bool rbf_editor: RBFEditorPreferences raw_control_editor: RawControlEditorPreferences shape_key_editor: ShapeKeyEditorPreferences diff --git a/src/addons/character_dna/ui/addon_preferences.py b/src/addons/character_dna/ui/addon_preferences.py index 6fb548ee..5b672493 100644 --- a/src/addons/character_dna/ui/addon_preferences.py +++ b/src/addons/character_dna/ui/addon_preferences.py @@ -40,16 +40,6 @@ def draw(self, context: "Context"): row = layout.row() row.prop(self, "metrics_collection", text="Allow Metrics Collection") - row = layout.row() - row.prop(self, "batched_evaluations", text="Batched Evaluations") - if not self.batched_evaluations: - row = layout.row() - row.label( - text="(Experimental) Disabling Batched Evaluations can cause instability and crashes. Please report " - "any issues and provide your crash logs.", - icon="ERROR", - ) - # Editor Settings (Pro only). The ``show_pro_features`` toggle lets Pro # users preview what the free edition's UI looks like. When the editors # submodule is absent (free edition), show a note advertising Pro instead. diff --git a/src/addons/character_dna/ui/callbacks.py b/src/addons/character_dna/ui/callbacks.py index 6ced7e79..0aec0eac 100644 --- a/src/addons/character_dna/ui/callbacks.py +++ b/src/addons/character_dna/ui/callbacks.py @@ -925,7 +925,7 @@ def update_body_output_items(self: "RigInstance", context: "Context"): # noqa: if not hasattr(context.scene, ToolInfo.NAME): return - from ..utilities import get_addon_scene_properties + from ..utilities import get_addon_scene_properties, remove_instance_prefix addon_scene_properties = get_addon_scene_properties(context) @@ -953,7 +953,7 @@ def update_body_output_items(self: "RigInstance", context: "Context"): # noqa: new_item.name = "rig" new_item.editable_name = False else: - new_item.name = scene_object.name.replace(f"{instance.name}_", "") + new_item.name = remove_instance_prefix(scene_object.name, instance.name) new_item.editable_name = True # update the output items for the image textures @@ -995,7 +995,7 @@ def update_head_output_items(self: "RigInstance | None", context: "Context"): # if not hasattr(context.scene, ToolInfo.NAME): return - from ..utilities import get_addon_scene_properties + from ..utilities import get_addon_scene_properties, remove_instance_prefix addon_scene_properties = get_addon_scene_properties(context) @@ -1023,7 +1023,7 @@ def update_head_output_items(self: "RigInstance | None", context: "Context"): # new_item.name = "rig" new_item.editable_name = False else: - new_item.name = scene_object.name.replace(f"{instance.name}_", "") + new_item.name = remove_instance_prefix(scene_object.name, instance.name) new_item.editable_name = True # update the output items for the image textures diff --git a/src/addons/character_dna/utilities/action.py b/src/addons/character_dna/utilities/action.py index ebd7ce3e..5a2242a8 100644 --- a/src/addons/character_dna/utilities/action.py +++ b/src/addons/character_dna/utilities/action.py @@ -11,10 +11,12 @@ from mathutils import Euler, Quaternion, Vector # local imports -from ..constants import EYE_AIM_BONES, FACE_BOARD_SWITCHES, IS_BLENDER_5, SCALE_FACTOR, Axis, ComponentType, ToolInfo +from ..constants import EYE_AIM_BONES, FACE_BOARD_SWITCHES, IS_BLENDER_5, Axis, ComponentType, ToolInfo +from ..fbx.reader import FbxAnimationClip, load_fbx_animation +from ..fbx.writer import assign_action, ensure_action, write_face_board_animation, write_skeleton_animation from ..typing import * # noqa: F403 +from ..validators import ValidationReport, validate_face_board_animation, validate_skeleton_animation from .armature import get_pose_bone_local_transform -from .misc import apply_transforms # blender 4.5 and 5.0 support @@ -75,137 +77,85 @@ def set_keys_on_bone( keyframe_point.co[1] = value * scale_factor -def remove_object_scale_keyframes(actions: list[bpy.types.Action]): - for action in actions: - if anim_utils: - channel_bag = anim_utils.action_ensure_channelbag_for_slot(action, action.slots[0]) - else: - channel_bag = action - - if channel_bag: - # Collect fcurves to remove first to avoid modifying collection while iterating - fcurves_to_remove = [fcurve for fcurve in channel_bag.fcurves if fcurve and fcurve.data_path == "scale"] - for fcurve in fcurves_to_remove: - channel_bag.fcurves.remove(fcurve) - +FACE_BOARD_EXCLUDED_CONTROLS = frozenset(EYE_AIM_BONES) | frozenset(FACE_BOARD_SWITCHES) -def scale_object_actions( - unordered_objects: list[bpy.types.Object], actions: list[bpy.types.Action], scale_factor: float -): - # get the list of objects that do not have parents - no_parents = [unordered_object for unordered_object in unordered_objects if not unordered_object.parent] - - # get the list of objects that have parents - parents = [unordered_object for unordered_object in unordered_objects if unordered_object.parent] - - # re-order the imported objects to have the top of the hierarchies iterated first - ordered_objects = no_parents + parents - for ordered_object in ordered_objects: - # run the export iteration but with "scale" set to the scale of the object as it was imported - scale = ordered_object.scale[:] - - # if the imported object is an armature - if ordered_object.type == "ARMATURE": - # iterate over any imported actions first this time... - for action in actions: - if anim_utils: - channel_bag = anim_utils.action_ensure_channelbag_for_slot(action, action.slots[0]) - else: - channel_bag = action +def get_scene_frame_rate() -> float: + """Return the current scene's frame rate in frames per second.""" + scene = bpy.context.scene + if not scene: + return 30.0 + return float(scene.render.fps) / float(scene.render.fps_base or 1.0) - if not channel_bag: - continue - # iterate through the location curves - for fcurve in [fcurve for fcurve in channel_bag.fcurves if fcurve.data_path.endswith("location")]: - # the location fcurve of the object - if fcurve.data_path == "location": - for keyframe_point in fcurve.keyframe_points: - # just the location to preserve root motion - keyframe_point.co[1] = keyframe_point.co[1] * scale[fcurve.array_index] * scale_factor - # don't scale the objects location handles - continue +def load_animation_clip(file_path: Path, match_frame_rate: bool = True) -> FbxAnimationClip: + """Read an FBX file into a resampled animation clip. - # and iterate through the keyframe values - for keyframe_point in fcurve.keyframe_points: - # multiply the location keyframes by the scale per channel - keyframe_point.co[1] = keyframe_point.co[1] * scale[fcurve.array_index] - keyframe_point.handle_left[1] = keyframe_point.handle_left[1] * scale[fcurve.array_index] - keyframe_point.handle_right[1] = keyframe_point.handle_right[1] * scale[fcurve.array_index] - - # apply the scale on the object - apply_transforms(ordered_object, scale=True) + Args: + file_path: Path to the FBX file. + match_frame_rate: Resample at the scene's frame rate. When ``False`` the + file's own frame rate is used instead. + Returns: + The loaded clip, already sampled onto whole frames. + """ + frame_rate = get_scene_frame_rate() if match_frame_rate else None + return load_fbx_animation(file_path, frame_rate=frame_rate) -def convert_action_rotation_from_quaternion_to_euler(action: bpy.types.Action, bone_names: list[str] | None = None): - rotation_curves_by_bone = {} - if bone_names is None: - bone_names = [] - if anim_utils: - channel_bag = anim_utils.action_ensure_channelbag_for_slot(action, action.slots[0]) - else: - channel_bag = action +def validate_animation_clip( + clip: FbxAnimationClip, + armature: bpy.types.Object, + component: str, + is_face_board: bool = False, +) -> ValidationReport: + """Check that a clip's nodes match the bones of the rig it will be written to. - if not channel_bag: - return + Args: + clip: The loaded animation. + armature: The target armature object. + component: Label for the target used in messages. + is_face_board: Validate against face board controls rather than a skeleton. - for fcurve in channel_bag.fcurves: - # save the quaternion rotation curves by bone for later conversion - if "rotation_quaternion" in fcurve.data_path: - bone_name = fcurve.data_path.split('"')[1] - # if we have a list of bone names to filter by, skip any that are not in the list - if bone_name not in bone_names: - continue - - rotation_curves_by_bone[bone_name] = rotation_curves_by_bone.get(bone_name, {}) - rotation_curves_by_bone[bone_name][fcurve.array_index] = fcurve - - # convert quaternion curves to euler curves - for bone_name, quat_curves in rotation_curves_by_bone.items(): - # collect all frames from all quaternion curves - frames = set() - for fcurve in quat_curves.values(): - for keyframe in fcurve.keyframe_points: - frames.add(int(keyframe.co[0])) - - # create euler fcurves - euler_fcurves = {} - for i in range(3): # x, y, z - euler_fcurves[i] = channel_bag.fcurves.new(data_path=f'pose.bones["{bone_name}"].rotation_euler', index=i) - euler_fcurves[i].keyframe_points.add(len(frames)) - - # convert quaternion values to euler for each frame - for frame_index, frame in enumerate(sorted(frames)): - quat_values = [1.0, 0.0, 0.0, 0.0] # w, x, y, z - for axis, fcurve in quat_curves.items(): - quat_values[axis] = fcurve.evaluate(frame) - - # convert quaternion to euler - quat = Quaternion(quat_values) - euler = quat.to_euler("XYZ") - - # set euler keyframe values - for i, value in enumerate([euler.x, euler.y, euler.z]): - euler_fcurves[i].keyframe_points[frame_index].co = (frame, value) - - # remove original quaternion curves - for fcurve in quat_curves.values(): - channel_bag.fcurves.remove(fcurve) + Returns: + The validation report. + """ + bone_names = [bone.name for bone in armature.pose.bones] if armature.pose else [] + if is_face_board: + controls = [name for name in bone_names if name not in FACE_BOARD_EXCLUDED_CONTROLS] + return validate_face_board_animation(clip.node_names, controls) + return validate_skeleton_animation(clip.node_names, bone_names, component=component) -def import_action_from_fbx( # noqa: PLR0912, PLR0915 +def import_action_from_fbx( instance: "RigInstance", file_path: Path, component: ComponentType, armature: bpy.types.Object, include_only_bones: list[str] | None = None, - round_sub_frames: bool = True, + round_sub_frames: bool = True, # noqa: ARG001 match_frame_rate: bool = True, prefix_instance_name: bool = True, prefix_component_name: bool = True, + clip: FbxAnimationClip | None = None, ) -> bpy.types.Action: + """Import a skeletal animation from an FBX file onto a component's armature. + + Args: + instance: The rig instance the armature belongs to. + file_path: Path to the FBX file. + component: Which component the animation is for. + armature: The target armature object. + include_only_bones: Only write these bones when given. + round_sub_frames: Unused; resampling always lands on whole frames. + match_frame_rate: Resample at the scene frame rate rather than the file's. + prefix_instance_name: Prefix the action name with the instance name. + prefix_component_name: Prefix the action name with the component name. + clip: An already loaded clip, to avoid reading the file twice. + + Returns: + The created Action, assigned to the armature. + """ file_path = Path(file_path) action_name = get_action_name( @@ -216,137 +166,45 @@ def import_action_from_fbx( # noqa: PLR0912, PLR0915 component=component, ) - # remove the action if it already exists - new_action = bpy.data.actions.get(action_name) - if new_action: - bpy.data.actions.remove(new_action) - new_action = bpy.data.actions.new(name=action_name) - - if anim_utils: - if len(new_action.slots) == 0: - new_action.slots.new("OBJECT", name=armature.name) - new_channel_bag = anim_utils.action_ensure_channelbag_for_slot(new_action, new_action.slots[0]) - else: - new_channel_bag = new_action - - if not new_channel_bag or not bpy.context.scene: - return new_action - - # remember the current actions and objects - current_actions = list(bpy.data.actions) - current_objects = list(bpy.data.objects) - # remember the current frame rate - current_frame_rate = bpy.context.scene.render.fps - # then import the fbx - bpy.ops.import_scene.fbx(filepath=str(file_path)) - - # apply the scale fixes since this was exported from unreal at 100x scale - imported_objects = [obj for obj in bpy.data.objects if obj not in current_objects] - imported_actions = [action for action in bpy.data.actions if action not in current_actions] - scale_object_actions(unordered_objects=imported_objects, actions=imported_actions, scale_factor=SCALE_FACTOR) - remove_object_scale_keyframes(actions=imported_actions) - - # get the frame rate of the imported fbx - imported_frame_rate = bpy.context.scene.render.fps - # calculate the frame scale factor - if match_frame_rate: - frame_scale_factor = current_frame_rate / imported_frame_rate - else: - frame_scale_factor = 1.0 - # restore the original frame rate - bpy.context.scene.render.fps = current_frame_rate - - # copy all the fcurves from the imported action to the new one - for action in bpy.data.actions: - if action in current_actions: - continue - - if anim_utils: - channel_bag = anim_utils.action_ensure_channelbag_for_slot(action, action.slots[0]) - else: - channel_bag = action - - if not channel_bag: - continue - - for source_fcurve in channel_bag.fcurves: - bone_name = None - curve_name = None - - if len(source_fcurve.data_path.split('"')) > 1: - bone_name = source_fcurve.data_path.split('"')[1] - curve_name = source_fcurve.data_path.split(".")[-1] - # object level transforms are mapped to the root bone - elif source_fcurve.data_path in {"location", "rotation_euler", "rotation_quaternion", "scale"}: - bone_name = "root" - curve_name = source_fcurve.data_path - - if bone_name and curve_name and armature.pose: - if not armature.pose.bones.get(bone_name): - logger.warning(f"Skipping fcurve for unknown bone: {bone_name}") - continue - - if include_only_bones and bone_name not in include_only_bones: - continue - - target_fcurve = new_channel_bag.fcurves.new( - data_path=f'pose.bones["{bone_name}"].{curve_name}', index=source_fcurve.array_index - ) - # then add as many points as keyframes - target_fcurve.keyframe_points.add(len(source_fcurve.keyframe_points)) - # then set all all their values - for index, keyframe in enumerate(source_fcurve.keyframe_points): - # Adjust keyframe position based on frame rate scale factor - frame = keyframe.co[0] * frame_scale_factor - - # optionally round sub frames to the nearest whole frame - if round_sub_frames: - frame = round(frame) - - target_fcurve.keyframe_points[index].co = (frame, keyframe.co[1]) - target_fcurve.keyframe_points[index].interpolation = keyframe.interpolation - - # assign the new action to as the current action of the armature - if not armature.animation_data: - armature.animation_data_create() - if not armature.animation_data: - raise RuntimeError("Failed to create animation data for armature.") - - armature.animation_data.action = new_action - # assign the first action slot if there are any - if new_action.slots: - armature.animation_data.action_slot = new_action.slots[0] + include_bones = set(include_only_bones) if include_only_bones else None + if clip is None: + clip = load_animation_clip(file_path, match_frame_rate=match_frame_rate) - # remove the imported actions - for action in bpy.data.actions: - if action not in current_actions: - bpy.data.actions.remove(action, do_unlink=True) + action = ensure_action(action_name) + written = write_skeleton_animation(clip, armature, action, include_bones=include_bones) + if not written: + logger.warning(f"No bones in '{armature.name}' matched any node in {file_path.name}.") - # remove the imported objects - for scene_object in bpy.data.objects: - if scene_object not in current_objects: - bpy.data.objects.remove(scene_object, do_unlink=True) - - if armature.pose: - # match the keyframe rotation modes to the armature bones (all rotation is imported as quaternion) - euler_bone_names = [b.name for b in armature.pose.bones if b.rotation_mode == "XYZ"] - convert_action_rotation_from_quaternion_to_euler(action=new_action, bone_names=euler_bone_names) - - return new_action + assign_action(armature, action) + return action -def import_face_board_action_from_fbx( # noqa: PLR0912 +def import_face_board_action_from_fbx( instance: "RigInstance", file_path: Path, armature: bpy.types.Object, - round_sub_frames: bool = True, + round_sub_frames: bool = True, # noqa: ARG001 match_frame_rate: bool = True, prefix_instance_name: bool = True, prefix_component_name: bool = True, -): + clip: FbxAnimationClip | None = None, +) -> bpy.types.Action: + """Import a face board animation from an FBX file onto the face board armature. + + Args: + instance: The rig instance the face board belongs to. + file_path: Path to the FBX file. + armature: The face board armature object. + round_sub_frames: Unused; resampling always lands on whole frames. + match_frame_rate: Resample at the scene frame rate rather than the file's. + prefix_instance_name: Prefix the action name with the instance name. + prefix_component_name: Prefix the action name with the component name. + clip: An already loaded clip, to avoid reading the file twice. + + Returns: + The created Action, assigned to the face board. + """ file_path = Path(file_path) - if not bpy.context.scene: - return action_name = get_action_name( instance=instance, @@ -356,98 +214,16 @@ def import_face_board_action_from_fbx( # noqa: PLR0912 component="face_board", # type: ignore[arg-type] ) - # remove the action if it already exists - face_board_action = bpy.data.actions.get(action_name) - if face_board_action: - bpy.data.actions.remove(face_board_action) - face_board_action = bpy.data.actions.new(name=action_name) + if clip is None: + clip = load_animation_clip(file_path, match_frame_rate=match_frame_rate) - if anim_utils: - if len(face_board_action.slots) == 0: - face_board_action.slots.new("OBJECT", name=armature.name) - face_board_channel_bag = anim_utils.action_ensure_channelbag_for_slot( - face_board_action, face_board_action.slots[0] - ) - else: - face_board_channel_bag = face_board_action - - # remember the current actions and objects - current_actions = list(bpy.data.actions) - current_objects = list(bpy.data.objects) - # remember the current frame rate - current_frame_rate = bpy.context.scene.render.fps - # then import the fbx - bpy.ops.import_scene.fbx(filepath=str(file_path)) - # get the frame rate of the imported fbx - imported_frame_rate = bpy.context.scene.render.fps - # calculate the frame scale factor - if match_frame_rate: - frame_scale_factor = current_frame_rate / imported_frame_rate - else: - frame_scale_factor = 1.0 - # restore the original frame rate - bpy.context.scene.render.fps = current_frame_rate - - # copy all the fcurves from the imported action to the new one - for action in bpy.data.actions: - if action in current_actions: - continue - - curve_name = action.name.split(".")[0] - # skip the face board action, only import controls - if curve_name == action.name: - continue - - # TODO: Change this to actually support these? - # skip any eye aim controls - if curve_name in EYE_AIM_BONES + FACE_BOARD_SWITCHES: - continue + action = ensure_action(action_name) + written = write_face_board_animation(clip, armature, action, exclude_bones=FACE_BOARD_EXCLUDED_CONTROLS) + if not written: + logger.warning(f"No face board controls matched any node in {file_path.name}.") - if anim_utils: - channel_bag = anim_utils.action_ensure_channelbag_for_slot(action, action.slots[0]) - else: - channel_bag = action - - if not channel_bag or not face_board_channel_bag: - continue - - for source_fcurve in channel_bag.fcurves: - target_fcurve = face_board_channel_bag.fcurves.new( - data_path=f'pose.bones["{curve_name}"].{source_fcurve.data_path}', index=source_fcurve.array_index - ) - # then add as many points as keyframes - target_fcurve.keyframe_points.add(len(source_fcurve.keyframe_points)) - # then set all all their values - for index, keyframe in enumerate(source_fcurve.keyframe_points): - # Adjust keyframe position based on frame rate scale factor - frame = keyframe.co[0] * frame_scale_factor - - # optionally round sub frames to the nearest whole frame - if round_sub_frames: - frame = round(frame) - - target_fcurve.keyframe_points[index].co = (frame, keyframe.co[1]) - target_fcurve.keyframe_points[index].interpolation = keyframe.interpolation - - # remove the imported objects - for scene_object in bpy.data.objects: - if scene_object not in current_objects: - bpy.data.objects.remove(scene_object) - # remove the imported actions - for action in bpy.data.actions: - if action not in current_actions: - bpy.data.actions.remove(action) - - # assign the new action to the face board - if not armature.animation_data: - armature.animation_data_create() - if not armature.animation_data: - raise RuntimeError("Failed to create animation data for armature.") - - armature.animation_data.action = face_board_action - # assign the first action slot if there are any - if face_board_action.slots: - armature.animation_data.action_slot = face_board_action.slots[0] + assign_action(armature, action) + return action def import_face_board_action_from_json(file_path: Path, armature: bpy.types.Object): # noqa: PLR0912 diff --git a/src/addons/character_dna/utilities/material.py b/src/addons/character_dna/utilities/material.py index 79f79ecb..01935bf1 100644 --- a/src/addons/character_dna/utilities/material.py +++ b/src/addons/character_dna/utilities/material.py @@ -11,7 +11,7 @@ from ..constants import MASKS_TEXTURE, MASKS_TEXTURE_FILE_PATH, UV_MAP_NAME, ComponentType # local imports -from .misc import editors_available, exclude_rig_instance_evaluation +from .misc import editors_available, exclude_rig_instance_evaluation, remove_instance_prefix, replace_instance_prefix logger = logging.getLogger(__name__) @@ -35,7 +35,7 @@ def copy_materials( material = slot.material if material: new_material = material.copy() - new_material.name = material.name.replace(old_prefix, new_prefix) + new_material.name = replace_instance_prefix(material.name, old_prefix, new_prefix) slot.material = new_material if not first_new_mesh_material: first_new_mesh_material = new_material @@ -46,7 +46,7 @@ def copy_materials( if node.type == "TEX_IMAGE": image = node.image # type: ignore[attr-defined] new_image = image.copy() - new_image.name = f"{new_prefix}_{image.name}".replace(f"{old_prefix}_", "") + new_image.name = f"{new_prefix}_{remove_instance_prefix(image.name, old_prefix)}" # copy the image files to the new folder if new_image.filepath and not new_image.packed_file: image_file_path = Path(bpy.path.abspath(new_image.filepath)) diff --git a/src/addons/character_dna/utilities/misc.py b/src/addons/character_dna/utilities/misc.py index 8a29b4e1..7fb28f0c 100644 --- a/src/addons/character_dna/utilities/misc.py +++ b/src/addons/character_dna/utilities/misc.py @@ -41,7 +41,7 @@ TEMP_FOLDER, ToolInfo, ) -from ..rig_instance import cancel_pending_evaluations, start_listening +from ..rig_instance import start_listening from ..typing import * # noqa: F403 from . import get_active_rig_instance @@ -331,9 +331,6 @@ def setup_scene(*_: Any) -> None: def teardown_scene(*_: Any) -> None: - # Cancel any queued deferred evaluation before the scene data is torn down/reloaded. - cancel_pending_evaluations() - scene_properties = getattr(bpy.context.scene, ToolInfo.NAME, object) for instance in getattr(scene_properties, "rig_instance_list", []): @@ -354,9 +351,6 @@ def pre_undo(*_: Any) -> None: if context.area and context.area.type == "VIEW_3D" and context.region and context.region.type == "WINDOW": addon_window_manager_properties.evaluate_dependency_graph = False addon_window_manager_properties.is_undoing = True - # Cancel any queued deferred evaluation: undo can free/reallocate the rig instance - # collection, which would leave the timer dereferencing dangling data and crash. - cancel_pending_evaluations() active_object = bpy.context.active_object # destroy cached data related rig instances, since undo can change the data # in a way that makes the cached data invalid @@ -477,6 +471,23 @@ def focus_on_selected(): bpy.ops.view3d.view_selected() +def remove_instance_prefix(name: str, instance_name: str) -> str: + """Remove one leading rig-instance namespace from a scene data-block name.""" + if not instance_name: + return name + return name.removeprefix(f"{instance_name}_") + + +def replace_instance_prefix(name: str, old_instance_name: str, new_instance_name: str) -> str: + """Replace an exact or leading rig-instance namespace in a data-block name.""" + if name == old_instance_name: + return new_instance_name + prefix = f"{old_instance_name}_" + if name.startswith(prefix): + return f"{new_instance_name}_{name.removeprefix(prefix)}" + return name + + def get_head(name: str) -> "CharacterComponentHead | None": # avoid circular import from ..components.head import CharacterComponentHead @@ -541,6 +552,34 @@ def move_to_collection(scene_objects: list[bpy.types.Object], collection_name: s collection.objects.link(scene_object) +def group_face_board_with_linked_collection( + face_board: bpy.types.Object, + linked_collection: bpy.types.Collection, + collection_name: str, +) -> None: + """Group a local face board together with a library-linked character collection. + + A local object cannot be linked into a library-linked collection, so a local wrapper + collection of the same name is created instead and the linked collection is nested + under it alongside the face board. Collection names are namespaced per library, so the + local wrapper keeps ``collection_name`` rather than gaining a ``.001`` suffix. + """ + scene = bpy.context.scene + if not scene: + return + + wrapper = bpy.data.collections.new(collection_name) + scene.collection.children.link(wrapper) + + if any(child == linked_collection for child in scene.collection.children): + scene.collection.children.unlink(linked_collection) + wrapper.children.link(linked_collection) + + for user_collection in face_board.users_collection: + user_collection.objects.unlink(face_board) + wrapper.objects.link(face_board) + + def set_origin_to_world_center(scene_object: bpy.types.Object): switch_to_object_mode() # set the active object @@ -568,27 +607,27 @@ def set_objects_origins(scene_objects: list[bpy.types.Object], location: Vector) def rename_rig_instance(instance: "RigInstance", old_name: str, new_name: str): if instance.face_board: - instance.face_board.name = instance.face_board.name.replace(old_name, new_name) - instance.face_board.data.name = instance.face_board.data.name.replace(old_name, new_name) + instance.face_board.name = replace_instance_prefix(instance.face_board.name, old_name, new_name) + instance.face_board.data.name = replace_instance_prefix(instance.face_board.data.name, old_name, new_name) if instance.head_mesh: - instance.head_mesh.name = instance.head_mesh.name.replace(old_name, new_name) - instance.head_mesh.data.name = instance.head_mesh.data.name.replace(old_name, new_name) + instance.head_mesh.name = replace_instance_prefix(instance.head_mesh.name, old_name, new_name) + instance.head_mesh.data.name = replace_instance_prefix(instance.head_mesh.data.name, old_name, new_name) if instance.head_rig: - instance.head_rig.name = instance.head_rig.name.replace(old_name, new_name) - instance.head_rig.data.name = instance.head_rig.data.name.replace(old_name, new_name) + instance.head_rig.name = replace_instance_prefix(instance.head_rig.name, old_name, new_name) + instance.head_rig.data.name = replace_instance_prefix(instance.head_rig.data.name, old_name, new_name) if instance.head_material: - instance.head_material.name = instance.head_material.name.replace(old_name, new_name) + instance.head_material.name = replace_instance_prefix(instance.head_material.name, old_name, new_name) if instance.body_mesh: - instance.body_mesh.name = instance.body_mesh.name.replace(old_name, new_name) - instance.body_mesh.data.name = instance.body_mesh.data.name.replace(old_name, new_name) + instance.body_mesh.name = replace_instance_prefix(instance.body_mesh.name, old_name, new_name) + instance.body_mesh.data.name = replace_instance_prefix(instance.body_mesh.data.name, old_name, new_name) if instance.body_rig: - instance.body_rig.name = instance.body_rig.name.replace(old_name, new_name) - instance.body_rig.data.name = instance.body_rig.data.name.replace(old_name, new_name) + instance.body_rig.name = replace_instance_prefix(instance.body_rig.name, old_name, new_name) + instance.body_rig.data.name = replace_instance_prefix(instance.body_rig.data.name, old_name, new_name) if instance.control_rig: - instance.control_rig.name = instance.control_rig.name.replace(old_name, new_name) - instance.control_rig.data.name = instance.control_rig.data.name.replace(old_name, new_name) + instance.control_rig.name = replace_instance_prefix(instance.control_rig.name, old_name, new_name) + instance.control_rig.data.name = replace_instance_prefix(instance.control_rig.data.name, old_name, new_name) if instance.body_material: - instance.body_material.name = instance.body_material.name.replace(old_name, new_name) + instance.body_material.name = replace_instance_prefix(instance.body_material.name, old_name, new_name) for item in instance.output.head_item_list.values() + instance.output.body_item_list.values(): # don't rename these again @@ -603,10 +642,10 @@ def rename_rig_instance(instance: "RigInstance", old_name: str, new_name: str): continue if item.scene_object: - item.scene_object.name = item.scene_object.name.replace(old_name, new_name) - item.scene_object.data.name = item.scene_object.data.name.replace(old_name, new_name) + item.scene_object.name = replace_instance_prefix(item.scene_object.name, old_name, new_name) + item.scene_object.data.name = replace_instance_prefix(item.scene_object.data.name, old_name, new_name) if item.image_object: - item.image_object.name = item.image_object.name.replace(old_name, new_name) + item.image_object.name = replace_instance_prefix(item.image_object.name, old_name, new_name) # rename the main collection main_collection = bpy.data.collections.get(old_name) @@ -617,7 +656,7 @@ def rename_rig_instance(instance: "RigInstance", old_name: str, new_name: str): for index in range(NUMBER_OF_HEAD_LODS): collection = bpy.data.collections.get(f"{old_name}_lod{index}") if collection: - collection.name = collection.name.replace(old_name, new_name) + collection.name = replace_instance_prefix(collection.name, old_name, new_name) # this frees up the instance data under the old name, since all data is # namespaced under the instance name @@ -1074,11 +1113,13 @@ def _rig_instance_sources(group: Any, key: str) -> list: """Return the list of rig-instance sources held under ``key`` on ``group``.""" if group is None: return [] - # Registered editions expose the list through the RNA collection; the old - # prototype stored it as a plain custom property. - if hasattr(group, "bl_rna") and key == "rig_instance_list": - return list(group.rig_instance_list) - data = group.get(key) if hasattr(group, "get") else None + # Registered editions expose the list as an RNA collection, but only when that + # edition defines this key -- the old prototype's registered group has no + # `rig_instance_list`, so reading it unguarded raises AttributeError. Anything not + # exposed through RNA was stored as a plain custom property. + data = getattr(group, key, None) if hasattr(group, "bl_rna") else None + if data is None and hasattr(group, "get"): + data = group.get(key) return list(data) if data else [] diff --git a/src/addons/character_dna/validators/__init__.py b/src/addons/character_dna/validators/__init__.py index 0887e9ad..7d1b2907 100644 --- a/src/addons/character_dna/validators/__init__.py +++ b/src/addons/character_dna/validators/__init__.py @@ -1,16 +1,23 @@ -from .rig_definition import ( - RigDefinitionValidator, - Severity, - ValidationIssue, - ValidationReport, - validate_dna_compatibility, +from .animation import ( + AnimationValidator, + FaceBoardAnimationValidator, + SkeletonAnimationValidator, + validate_face_board_animation, + validate_skeleton_animation, ) +from .base import Severity, ValidationIssue, ValidationReport +from .rig_definition import RigDefinitionValidator, validate_dna_compatibility __all__ = [ + "AnimationValidator", + "FaceBoardAnimationValidator", "RigDefinitionValidator", "Severity", + "SkeletonAnimationValidator", "ValidationIssue", "ValidationReport", "validate_dna_compatibility", + "validate_face_board_animation", + "validate_skeleton_animation", ] diff --git a/src/addons/character_dna/validators/animation.py b/src/addons/character_dna/validators/animation.py new file mode 100644 index 00000000..a69f36f5 --- /dev/null +++ b/src/addons/character_dna/validators/animation.py @@ -0,0 +1,282 @@ +"""Validate that an FBX animation actually belongs to the rig it is being imported onto. + +The checks are deliberately name based and Blender free: they compare the node +names in an FBX clip against the bone names of the target rig. Extra nodes in +the FBX are expected and never block an import; a clip that clearly belongs to a +different rig does. +""" + +# standard library imports +import logging + +from collections.abc import Iterable + +# local imports +from .base import Severity, ValidationReport + + +logger = logging.getLogger(__name__) + +# Fraction of the smaller of the two name sets that must match. Comparing against +# the smaller set means neither a rig with extra bones nor an FBX with extra nodes +# is penalized, which is the whole point of the check. +MINIMUM_BONE_COVERAGE = 0.6 + +# Guards the degenerate case where a tiny FBX matches a handful of bones and would +# otherwise score perfect coverage. +MINIMUM_MATCHED_BONES = 8 + +# Bones that only ever appear on a MetaHuman body/head skeleton. +SKELETON_SIGNATURE_BONES = frozenset( + { + "pelvis", + "spine_01", + "spine_02", + "spine_03", + "clavicle_l", + "clavicle_r", + "upperarm_l", + "upperarm_r", + "thigh_l", + "thigh_r", + "hand_l", + "hand_r", + "neck_01", + "head", + } +) + +# Face board controls are uniformly prefixed. +FACE_BOARD_CONTROL_PREFIX = "CTRL_" + +# Fraction of names that must be controls before a clip is considered a face board clip. +FACE_BOARD_CONTROL_RATIO = 0.5 + +# Number of names listed in a message before it is truncated. +MAX_REPORTED_NAMES = 10 + + +def _format_names(names: Iterable[str]) -> str: + """Return a short, sorted, comma separated preview of ``names``.""" + ordered = sorted(names) + preview = ", ".join(ordered[:MAX_REPORTED_NAMES]) + if len(ordered) > MAX_REPORTED_NAMES: + preview += f", ... (+{len(ordered) - MAX_REPORTED_NAMES} more)" + return preview + + +def looks_like_face_board(names: Iterable[str]) -> bool: + """Return ``True`` when a set of node names looks like a face board clip. + + Args: + names: Node or bone names to inspect. + + Returns: + ``True`` when most of the names are face board controls. + """ + names = list(names) + if not names: + return False + controls = sum(1 for name in names if name.startswith(FACE_BOARD_CONTROL_PREFIX)) + return controls / len(names) >= FACE_BOARD_CONTROL_RATIO + + +def looks_like_skeleton(names: Iterable[str]) -> bool: + """Return ``True`` when a set of node names looks like a MetaHuman skeleton clip. + + Args: + names: Node or bone names to inspect. + + Returns: + ``True`` when enough signature body bones are present. + """ + matched = SKELETON_SIGNATURE_BONES.intersection(names) + return len(matched) >= 4 + + +class AnimationValidator: + """Compare the node names in an FBX clip against a target rig's bone names.""" + + #: Human readable name of the kind of animation this validator accepts. + expected_kind = "animation" + + def __init__( + self, + source_names: Iterable[str], + target_names: Iterable[str], + subject: str, + minimum_coverage: float = MINIMUM_BONE_COVERAGE, + ) -> None: + """Initialize the validator. + + Args: + source_names: Node names found in the FBX file. + target_names: Bone names the import would write to. + subject: Label for the target used in messages, such as ``"body"``. + minimum_coverage: Fraction of the smaller name set that must match. + """ + self._source_names = frozenset(source_names) + self._target_names = frozenset(target_names) + self._subject = subject + self._minimum_coverage = minimum_coverage + + @property + def matched_names(self) -> frozenset[str]: + """Names present in both the FBX and the target rig.""" + return self._source_names & self._target_names + + @property + def coverage(self) -> float: + """Fraction of the smaller name set that matched, in ``[0, 1]``.""" + smaller = min(len(self._source_names), len(self._target_names)) + if smaller == 0: + return 0.0 + return len(self.matched_names) / smaller + + def validate(self) -> ValidationReport: + """Run every check. + + Returns: + The collected report. Errors mean the file is very likely wrong. + """ + report = ValidationReport(db_name=self._subject) + if not self._source_names: + report.add( + code="no_animation_nodes", + severity=Severity.ERROR, + message="The FBX file does not contain any animated nodes.", + expected=self.expected_kind, + actual=None, + ) + return report + + if not self._target_names: + report.add( + code="no_target_bones", + severity=Severity.ERROR, + message=f"The {self._subject} rig has no bones to import animation onto.", + expected=self._subject, + actual=None, + ) + return report + + self._check_animation_type(report) + self._check_coverage(report) + self._check_unexpected_nodes(report) + return report + + def _check_animation_type(self, report: ValidationReport) -> None: + """Add an issue when the clip clearly belongs to a different kind of rig.""" + + def _check_coverage(self, report: ValidationReport) -> None: + matched = self.matched_names + coverage = self.coverage + if coverage >= self._minimum_coverage and len(matched) >= MINIMUM_MATCHED_BONES: + return + + missing = self._target_names - self._source_names + report.add( + code="bone_coverage_too_low", + severity=Severity.ERROR, + message=( + f"This file does not look like {self._subject} animation. Only " + f"{len(matched)} of the {len(self._target_names)} {self._subject} bones were found " + f"in it ({coverage:.0%} match).\n" + f"Missing bones: {_format_names(missing)}" + ), + expected=self._minimum_coverage, + actual=coverage, + ) + + def _check_unexpected_nodes(self, report: ValidationReport) -> None: + unexpected = self._source_names - self._target_names + if not unexpected: + return + report.add( + code="node_unexpected", + severity=Severity.WARNING, + message=( + f"The FBX file contains {len(unexpected)} node(s) with no matching " + f"{self._subject} bone; they were ignored: {_format_names(unexpected)}" + ), + expected=None, + actual=len(unexpected), + ) + + +class SkeletonAnimationValidator(AnimationValidator): + """Validate a clip that is being imported onto a body or head skeleton.""" + + expected_kind = "skeletal animation" + + def _check_animation_type(self, report: ValidationReport) -> None: + if not looks_like_face_board(self._source_names): + return + report.add( + code="wrong_animation_type", + severity=Severity.ERROR, + message=( + f"This looks like face board animation, not {self._subject} animation. " + f"Import it with the Face Board importer instead." + ), + expected=self.expected_kind, + actual="face board animation", + ) + + +class FaceBoardAnimationValidator(AnimationValidator): + """Validate a clip that is being imported onto the face board.""" + + expected_kind = "face board animation" + + def _check_animation_type(self, report: ValidationReport) -> None: + if not looks_like_skeleton(self._source_names): + return + report.add( + code="wrong_animation_type", + severity=Severity.ERROR, + message=( + "This looks like body or head skeleton animation, not face board animation. " + "Import it with the Body or Head importer instead." + ), + expected=self.expected_kind, + actual="skeletal animation", + ) + + +def validate_skeleton_animation( + source_names: Iterable[str], + target_names: Iterable[str], + component: str = "body", + minimum_coverage: float = MINIMUM_BONE_COVERAGE, +) -> ValidationReport: + """Validate an FBX clip against a body or head skeleton. + + Args: + source_names: Node names found in the FBX file. + target_names: Bone names on the target rig. + component: ``"body"`` or ``"head"``, used in messages. + minimum_coverage: Fraction of the smaller name set that must match. + + Returns: + The validation report. + """ + return SkeletonAnimationValidator(source_names, target_names, component, minimum_coverage).validate() + + +def validate_face_board_animation( + source_names: Iterable[str], + target_names: Iterable[str], + minimum_coverage: float = MINIMUM_BONE_COVERAGE, +) -> ValidationReport: + """Validate an FBX clip against the face board. + + Args: + source_names: Node names found in the FBX file. + target_names: Control bone names on the face board. + minimum_coverage: Fraction of the smaller name set that must match. + + Returns: + The validation report. + """ + return FaceBoardAnimationValidator(source_names, target_names, "face board", minimum_coverage).validate() diff --git a/src/addons/character_dna/validators/base.py b/src/addons/character_dna/validators/base.py new file mode 100644 index 00000000..ea0c4eaa --- /dev/null +++ b/src/addons/character_dna/validators/base.py @@ -0,0 +1,67 @@ +# standard library imports +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + + +class Severity(Enum): + """Severity level of a validation issue.""" + + ERROR = "error" + WARNING = "warning" + + +@dataclass(frozen=True) +class ValidationIssue: + """A single problem found while validating.""" + + code: str + severity: Severity + message: str + expected: Any = None + actual: Any = None + + +@dataclass +class ValidationReport: + """The collected result of a validation pass.""" + + db_name: str + issues: list[ValidationIssue] = field(default_factory=list) + + def add( + self, + code: str, + severity: Severity, + message: str, + expected: Any = None, + actual: Any = None, + ) -> None: + self.issues.append( + ValidationIssue(code=code, severity=severity, message=message, expected=expected, actual=actual) + ) + + @property + def errors(self) -> list[ValidationIssue]: + return [issue for issue in self.issues if issue.severity is Severity.ERROR] + + @property + def warnings(self) -> list[ValidationIssue]: + return [issue for issue in self.issues if issue.severity is Severity.WARNING] + + @property + def is_valid(self) -> bool: + """``True`` when there are no error-severity issues.""" + return not self.errors + + def summary(self, severity: Severity | None = Severity.ERROR) -> str: + """Return the issue messages as newline separated text. + + Args: + severity: Only include issues of this severity, or ``None`` for all. + + Returns: + One message per line, in the order the issues were added. + """ + issues = self.issues if severity is None else [issue for issue in self.issues if issue.severity is severity] + return "\n".join(issue.message for issue in issues) diff --git a/src/addons/character_dna/validators/rig_definition.py b/src/addons/character_dna/validators/rig_definition.py index fb5de8af..0b2d2150 100644 --- a/src/addons/character_dna/validators/rig_definition.py +++ b/src/addons/character_dna/validators/rig_definition.py @@ -1,13 +1,10 @@ # standard library imports import logging -from dataclasses import dataclass, field -from enum import Enum -from typing import Any - # local imports from ..rig_definition import RigDefinition, get_rig_definition from ..typing import * # noqa: F403 +from .base import Severity, ValidationReport logger = logging.getLogger(__name__) @@ -15,60 +12,6 @@ DEFAULT_COMPONENT = "head" -# ---------------------------------------------------------------------------------------------- -# Validation result types -# ---------------------------------------------------------------------------------------------- -class Severity(Enum): - """Severity level of a validation issue.""" - - ERROR = "error" - WARNING = "warning" - - -@dataclass(frozen=True) -class ValidationIssue: - """A single problem found while validating a DNA against a rig definition.""" - - code: str - severity: Severity - message: str - expected: Any = None - actual: Any = None - - -@dataclass -class ValidationReport: - """The collected result of validating a DNA against a rig definition.""" - - db_name: str - issues: list[ValidationIssue] = field(default_factory=list) - - def add( - self, - code: str, - severity: Severity, - message: str, - expected: Any = None, - actual: Any = None, - ) -> None: - self.issues.append( - ValidationIssue(code=code, severity=severity, message=message, expected=expected, actual=actual) - ) - - @property - def errors(self) -> list[ValidationIssue]: - return [issue for issue in self.issues if issue.severity is Severity.ERROR] - - @property - def warnings(self) -> list[ValidationIssue]: - return [issue for issue in self.issues if issue.severity is Severity.WARNING] - - @property - def is_valid(self) -> bool: - """``True`` when there are no error-severity issues.""" - return not self.errors - - # ---------------------------------------------------------------------------------------------- # Validator # ---------------------------------------------------------------------------------------------- diff --git a/src/addons/character_dna/wheels/pyufbx-0.0.0-cp311-cp311-macosx_11_0_arm64.whl b/src/addons/character_dna/wheels/pyufbx-0.0.0-cp311-cp311-macosx_11_0_arm64.whl new file mode 100644 index 00000000..88a36d53 Binary files /dev/null and b/src/addons/character_dna/wheels/pyufbx-0.0.0-cp311-cp311-macosx_11_0_arm64.whl differ diff --git a/src/addons/character_dna/wheels/pyufbx-0.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl b/src/addons/character_dna/wheels/pyufbx-0.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl new file mode 100644 index 00000000..c2a66d39 Binary files /dev/null and b/src/addons/character_dna/wheels/pyufbx-0.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl differ diff --git a/src/addons/character_dna/wheels/pyufbx-0.0.0-cp311-cp311-win_amd64.whl b/src/addons/character_dna/wheels/pyufbx-0.0.0-cp311-cp311-win_amd64.whl new file mode 100644 index 00000000..e65761e3 Binary files /dev/null and b/src/addons/character_dna/wheels/pyufbx-0.0.0-cp311-cp311-win_amd64.whl differ diff --git a/src/addons/character_dna/wheels/pyufbx-0.0.0-cp313-cp313-macosx_11_0_arm64.whl b/src/addons/character_dna/wheels/pyufbx-0.0.0-cp313-cp313-macosx_11_0_arm64.whl new file mode 100644 index 00000000..38e6d38e Binary files /dev/null and b/src/addons/character_dna/wheels/pyufbx-0.0.0-cp313-cp313-macosx_11_0_arm64.whl differ diff --git a/src/addons/character_dna/wheels/pyufbx-0.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl b/src/addons/character_dna/wheels/pyufbx-0.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl new file mode 100644 index 00000000..26505427 Binary files /dev/null and b/src/addons/character_dna/wheels/pyufbx-0.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl differ diff --git a/src/addons/character_dna/wheels/pyufbx-0.0.0-cp313-cp313-win_amd64.whl b/src/addons/character_dna/wheels/pyufbx-0.0.0-cp313-cp313-win_amd64.whl new file mode 100644 index 00000000..9deae3ac Binary files /dev/null and b/src/addons/character_dna/wheels/pyufbx-0.0.0-cp313-cp313-win_amd64.whl differ diff --git a/tests/test_animation.py b/tests/test_animation.py index d0175186..6655e43a 100644 --- a/tests/test_animation.py +++ b/tests/test_animation.py @@ -40,6 +40,42 @@ def test_import_face_board_animation(load_full_dna_for_animation, file_name: str assert instance.face_board.animation_data.action.name == f"{instance.name}_face_board_{file_path.stem}" +def test_body_animation_onto_face_board_is_rejected(load_full_dna_for_animation): + instance = get_active_rig_instance() + file_path = TEST_ANIMATION_FOLDER / "body" / "MHC_BodyROM.fbx" + action_before = instance.face_board.animation_data.action if instance.face_board.animation_data else None + + # Headless runs have nobody to answer the confirmation dialog, so the import + # refuses instead of guessing. + with pytest.raises(RuntimeError, match="face board"): + bpy.ops.character_dna.import_face_board_animation(filepath=str(file_path)) + + action_after = instance.face_board.animation_data.action if instance.face_board.animation_data else None + assert action_after == action_before + + +def test_face_board_animation_onto_body_is_rejected(load_full_dna_for_animation): + instance = get_active_rig_instance() + file_path = TEST_ANIMATION_FOLDER / "head" / "MHC_FaceBoardROM.fbx" + action_before = instance.body_rig.animation_data.action if instance.body_rig.animation_data else None + + with pytest.raises(RuntimeError, match="body"): + bpy.ops.character_dna.import_component_animation(component_type="body", filepath=str(file_path)) + + action_after = instance.body_rig.animation_data.action if instance.body_rig.animation_data else None + assert action_after == action_before + + +def test_validation_can_be_overridden(load_full_dna_for_animation): + instance = get_active_rig_instance() + file_path = TEST_ANIMATION_FOLDER / "body" / "MHC_BodyROM.fbx" + + result = bpy.ops.character_dna.import_face_board_animation(filepath=str(file_path), ignore_validation=True) + + assert result == {"FINISHED"} + assert instance.face_board.animation_data.action.name == f"{instance.name}_face_board_{file_path.stem}" + + @pytest.mark.parametrize( ("component", "action_name", "prefix_instance_name", "prefix_component_name", "replace_action"), [ diff --git a/tests/test_animation_validators.py b/tests/test_animation_validators.py new file mode 100644 index 00000000..75e1fd82 --- /dev/null +++ b/tests/test_animation_validators.py @@ -0,0 +1,157 @@ +import pytest + +from character_dna.validators import ( + Severity, + validate_face_board_animation, + validate_skeleton_animation, +) + + +BODY_BONES = [ + "root", + "pelvis", + "spine_01", + "spine_02", + "spine_03", + "clavicle_l", + "clavicle_r", + "upperarm_l", + "upperarm_r", + "thigh_l", + "thigh_r", + "hand_l", + "hand_r", + "neck_01", + "head", +] + +FACE_BOARD_CONTROLS = [ + "CTRL_C_jaw", + "CTRL_C_jaw_fwdBack", + "CTRL_C_jaw_openExtreme", + "CTRL_L_jaw_clench", + "CTRL_R_jaw_clench", + "CTRL_L_neck_stretch", + "CTRL_R_neck_stretch", + "CTRL_neck_digastricUpDown", + "CTRL_L_mouth_cornerPull", + "CTRL_R_mouth_cornerPull", + "CTRL_L_brow_down", + "CTRL_R_brow_down", +] + + +def codes(report) -> set[str]: + return {issue.code for issue in report.issues} + + +def test_matching_skeleton_animation_is_valid(): + report = validate_skeleton_animation(BODY_BONES, BODY_BONES, component="body") + + assert report.is_valid + assert not report.issues + + +def test_matching_face_board_animation_is_valid(): + report = validate_face_board_animation(FACE_BOARD_CONTROLS, FACE_BOARD_CONTROLS) + + assert report.is_valid + + +def test_extra_fbx_nodes_only_warn(): + source = [*BODY_BONES, "extra_prop_bone", "camera_helper"] + + report = validate_skeleton_animation(source, BODY_BONES, component="body") + + assert report.is_valid + assert codes(report) == {"node_unexpected"} + assert report.warnings[0].severity is Severity.WARNING + + +def test_extra_rig_bones_do_not_fail_validation(): + target = [*BODY_BONES, *[f"twist_{index}" for index in range(200)]] + + report = validate_skeleton_animation(BODY_BONES, target, component="body") + + assert report.is_valid + + +def test_face_board_animation_onto_body_is_rejected(): + report = validate_skeleton_animation(FACE_BOARD_CONTROLS, BODY_BONES, component="body") + + assert not report.is_valid + assert "wrong_animation_type" in codes(report) + assert "face board" in report.summary() + + +def test_body_animation_onto_face_board_is_rejected(): + report = validate_face_board_animation(BODY_BONES, FACE_BOARD_CONTROLS) + + assert not report.is_valid + assert "wrong_animation_type" in codes(report) + assert "skeleton animation" in report.summary() + + +def test_empty_source_is_rejected(): + report = validate_skeleton_animation([], BODY_BONES, component="body") + + assert not report.is_valid + assert codes(report) == {"no_animation_nodes"} + + +def test_empty_target_is_rejected(): + report = validate_skeleton_animation(BODY_BONES, [], component="body") + + assert not report.is_valid + assert codes(report) == {"no_target_bones"} + + +@pytest.mark.parametrize( + ("matched_count", "expected_valid"), + [ + (len(BODY_BONES), True), + (10, True), # 10/15 == 67%, above the default 60% threshold + (8, False), # 8/15 == 53%, below it + (2, False), # too few matches regardless of ratio + ], +) +def test_coverage_threshold(matched_count: int, expected_valid: bool): + source = [*BODY_BONES[:matched_count], *[f"unrelated_{index}" for index in range(len(BODY_BONES) - matched_count)]] + + report = validate_skeleton_animation(source, BODY_BONES, component="body") + + assert report.is_valid is expected_valid + if not expected_valid: + assert "bone_coverage_too_low" in codes(report) + + +def test_a_tiny_perfectly_matching_file_is_still_rejected(): + # A handful of matching names would otherwise score 100% coverage. + source = BODY_BONES[:3] + + report = validate_skeleton_animation(source, BODY_BONES, component="body") + + assert not report.is_valid + assert "bone_coverage_too_low" in codes(report) + + +def test_missing_bones_are_listed_in_the_message(): + # Pad with unrelated names so the target is the smaller set and coverage is + # measured against it. + matched = [name for name in BODY_BONES if name != "pelvis"][:8] + source = [*matched, *[f"unrelated_{index}" for index in range(20)]] + + report = validate_skeleton_animation(source, BODY_BONES, component="body") + + assert not report.is_valid + assert "pelvis" in report.summary() + + +def test_summary_filters_by_severity(): + source = [*BODY_BONES, "extra_bone"] + + report = validate_skeleton_animation(source, BODY_BONES, component="body") + + assert report.summary() == "" + assert "extra_bone" in report.summary(Severity.WARNING) + assert "extra_bone" in report.summary(None) diff --git a/tests/test_fbx_reader.py b/tests/test_fbx_reader.py new file mode 100644 index 00000000..3866f032 --- /dev/null +++ b/tests/test_fbx_reader.py @@ -0,0 +1,136 @@ +import numpy as np +import pytest + +from character_dna.fbx import load_fbx_animation +from character_dna.fbx.maths import quat_multiply, quat_to_euler, quat_to_matrix +from constants import TEST_ANIMATION_FOLDER + + +ANIMATION_FILES = [ + ("body/MHC_BodyROM.fbx", "root"), + ("head/MHC_HeadROM.fbx", "root"), + ("head/MHC_FaceBoardROM.fbx", "Face_ControlBoard_CtrlRig"), +] + + +@pytest.fixture(scope="module", params=ANIMATION_FILES, ids=lambda value: value[0]) +def clip(request): + relative_path, root_name = request.param + return load_fbx_animation(TEST_ANIMATION_FOLDER / relative_path), root_name + + +def test_clip_shapes_are_consistent(clip): + animation, _ = clip + + assert animation.num_nodes == len(animation.node_names) > 0 + assert animation.num_frames > 0 + assert animation.rotations.shape == (animation.num_frames, animation.num_nodes, 4) + assert animation.translations.shape == (animation.num_frames, animation.num_nodes, 3) + assert animation.rest_rotations.shape == (animation.num_nodes, 4) + assert animation.rest_translations.shape == (animation.num_nodes, 3) + assert animation.parent_indices.shape == (animation.num_nodes,) + + +def test_unreal_export_metadata(clip): + animation, _ = clip + + # These files are exported from Unreal, which is Z up and centimeters. + assert animation.up_axis == "Z" + assert animation.unit_meters == pytest.approx(0.01) + assert animation.frame_rate > 0.0 + assert animation.take_name + + +def test_hierarchy_is_topologically_ordered(clip): + animation, root_name = clip + + assert animation.node_names[0] == root_name + assert animation.parent_indices[0] == -1 + # Every parent must appear before its child so hierarchy walks are a single pass. + for index, parent in enumerate(animation.parent_indices): + assert parent < index + + +def test_rotations_are_unit_quaternions(clip): + animation, _ = clip + + norms = np.linalg.norm(animation.rotations, axis=-1) + assert np.allclose(norms, 1.0, atol=1e-6) + assert np.allclose(np.linalg.norm(animation.rest_rotations, axis=-1), 1.0, atol=1e-6) + + +def test_animated_nodes_are_reported(clip): + animation, _ = clip + + assert animation.animated_node_names + assert animation.animated_node_names <= set(animation.node_names) + + +def test_node_indices_resolve_names(clip): + animation, root_name = clip + + assert animation.node_indices[root_name] == 0 + for name in animation.node_names: + assert animation.node_names[animation.node_indices[name]] == name + + +def test_explicit_frame_rate_changes_sample_count(): + file_path = TEST_ANIMATION_FOLDER / "body" / "MHC_BodyROM.fbx" + + single_rate = load_fbx_animation(file_path, frame_rate=30.0) + double_rate = load_fbx_animation(file_path, frame_rate=60.0) + + assert double_rate.num_frames > single_rate.num_frames + assert double_rate.frame_rate == 60.0 + + +def test_missing_file_raises(): + with pytest.raises(FileNotFoundError): + load_fbx_animation(TEST_ANIMATION_FOLDER / "does_not_exist.fbx") + + +@pytest.mark.parametrize("order", ["XYZ", "XZY", "YXZ", "YZX", "ZXY", "ZYX"]) +def test_quat_to_euler_round_trips_through_mathutils(order: str): + from mathutils import Quaternion + + rng = np.random.default_rng(seed=7) + quaternions = rng.normal(size=(32, 4)) + quaternions /= np.linalg.norm(quaternions, axis=-1, keepdims=True) + quaternions[quaternions[:, 0] < 0] *= -1.0 + + ours = quat_to_euler(quaternions, order) + + for index, quaternion in enumerate(quaternions): + expected = Quaternion(quaternion.tolist()).to_euler(order) + # mathutils works in single precision, so this is its noise floor. + assert ours[index] == pytest.approx(list(expected), abs=1e-5) + + +def test_quat_to_matrix_matches_mathutils(): + from mathutils import Quaternion + + rng = np.random.default_rng(seed=11) + quaternions = rng.normal(size=(16, 4)) + quaternions /= np.linalg.norm(quaternions, axis=-1, keepdims=True) + + matrices = quat_to_matrix(quaternions) + + for index, quaternion in enumerate(quaternions): + expected = Quaternion(quaternion.tolist()).to_matrix() + assert np.allclose(matrices[index], np.asarray(expected), atol=1e-6) + + +def test_quat_multiply_matches_mathutils(): + from mathutils import Quaternion + + rng = np.random.default_rng(seed=13) + left = rng.normal(size=(16, 4)) + right = rng.normal(size=(16, 4)) + left /= np.linalg.norm(left, axis=-1, keepdims=True) + right /= np.linalg.norm(right, axis=-1, keepdims=True) + + products = quat_multiply(left, right) + + for index in range(left.shape[0]): + expected = Quaternion(left[index].tolist()) @ Quaternion(right[index].tolist()) + assert products[index] == pytest.approx(list(expected), abs=1e-6) diff --git a/tests/test_legacy_data_migration.py b/tests/test_legacy_data_migration.py index 8aa2c9da..572c4650 100644 --- a/tests/test_legacy_data_migration.py +++ b/tests/test_legacy_data_migration.py @@ -78,3 +78,23 @@ def test_cross_edition_migration_skips_existing_names(empty_scene: bpy.types.Sce # The existing instance is not duplicated. names = [instance.name for instance in empty_scene.character_dna.rig_instance_list] assert names.count("Ada") == 1 + + +def test_detection_survives_group_without_rig_instance_list(empty_scene: bpy.types.Scene): + """A registered group from another addon may not define ``rig_instance_list``. + + The old ``meta_human_dna`` prototype's scene group exposes ``bl_rna`` but stores its + instances under a different name, so reading the attribute unguarded raised + AttributeError on every panel redraw. See issue #341's sibling report and + CHARACTER-DNA-ADDON-MHQ. + """ + from character_dna.utilities.misc import _rig_instance_sources + + class ForeignGroup: + bl_rna = object() + + def get(self, _key, default=None): + return default + + assert _rig_instance_sources(ForeignGroup(), "rig_instance_list") == [] + assert detect_legacy_data(empty_scene) is None diff --git a/tests/test_name_prefixes.py b/tests/test_name_prefixes.py new file mode 100644 index 00000000..a8802656 --- /dev/null +++ b/tests/test_name_prefixes.py @@ -0,0 +1,74 @@ +import shutil + +import pytest + +from character_dna.utilities import remove_instance_prefix, replace_instance_prefix +from constants import HEAD_DNA_FILE +from fixtures.scene import load_dna + + +@pytest.mark.parametrize( + ("name", "instance_name", "expected"), + [ + ("Ada_head_lod0_mesh", "Ada", "head_lod0_mesh"), + ("head_head_lod0_mesh", "head", "head_lod0_mesh"), + ("head_lod0_mesh", "Ada", "head_lod0_mesh"), + ("_head_lod0_mesh", "", "_head_lod0_mesh"), + ], +) +def test_remove_instance_prefix(name: str, instance_name: str, expected: str) -> None: + assert remove_instance_prefix(name, instance_name) == expected + + +@pytest.mark.parametrize( + ("name", "old_instance_name", "new_instance_name", "expected"), + [ + ("head", "head", "Ada", "Ada"), + ("head_head_lod0_mesh", "head", "Ada", "Ada_head_lod0_mesh"), + ("custom_head_mesh", "head", "Ada", "custom_head_mesh"), + ], +) +def test_replace_instance_prefix( + name: str, old_instance_name: str, new_instance_name: str, expected: str +) -> None: + assert replace_instance_prefix(name, old_instance_name, new_instance_name) == expected + + +def test_standalone_head_dna_preserves_shape_key_mesh_name(addon, temp_folder) -> None: + from character_dna.dna_io import get_dna_reader + from character_dna.editors.shape_key_editor import callbacks + from character_dna.utilities import get_active_rig_instance + + dna_path = temp_folder / "standalone" / "head.dna" + dna_path.parent.mkdir(parents=True) + shutil.copy(HEAD_DNA_FILE, dna_path) + + load_dna( + file_path=dna_path, + import_lods=["lod0"], + include_body=False, + import_shape_keys=False, + import_face_board=False, + ) + + instance = get_active_rig_instance() + assert instance is not None + assert instance.name == "head" + + reader = get_dna_reader(dna_path) + instance.data[instance.cache_key("head", "dna_reader")] = reader + mesh_index = next(iter(reader.getMeshIndicesForLOD(0))) + mesh_object = instance.head_mesh_index_lookup[mesh_index] + assert mesh_object.name == "head_head_lod0_mesh" + + mesh_object.shape_key_add(name="Basis", from_mix=False) + channel_index = reader.getBlendShapeChannelIndex(mesh_index, 0) + channel_name = reader.getBlendShapeChannelName(channel_index) + shape_key = mesh_object.shape_key_add(name=f"head_lod0_mesh__{channel_name}", from_mix=False) + + enum_items = callbacks.get_active_shape_key_mesh_names(None, None) + assert enum_items[0][0] == "head_head_lod0_mesh" + assert enum_items[0][1] == "head_lod0_mesh" + + instance.data.pop(instance.cache_key("head", "shape_key_blocks"), None) + assert shape_key in instance.head_shape_key_blocks[channel_index] diff --git a/tests/test_reference_blend_file.py b/tests/test_reference_blend_file.py index 60123ccd..3f311400 100644 --- a/tests/test_reference_blend_file.py +++ b/tests/test_reference_blend_file.py @@ -54,3 +54,17 @@ def test_reference_blend_file( assert instance.head_rig is not None, f"Head rig should be created for {name}" assert instance.head_mesh is not None, f"Head mesh should be created for {name}" assert instance.head_dna_file_path is not None, f"Head DNA file path should be set for {name}" + + # The face board must be grouped in the instance's collection for both operations, not + # left loose in the scene root. See issue #341. + for name in metahuman_names: + instance = bpy.context.scene.character_dna.rig_instance_list.get(name) # type: ignore + assert instance and instance.face_board, f"Face board should be created for {name}" + face_board_collections = [c.name for c in instance.face_board.users_collection] + assert face_board_collections == [name], ( + f"Face board for {name} should only be in the {name} collection, got {face_board_collections}" + ) + root_objects = [o.name for o in bpy.context.scene.collection.objects] + assert instance.face_board.name not in root_objects, ( + f"Face board for {name} should not be loose in the scene root collection" + ) diff --git a/uv.lock b/uv.lock index 72f5ee7a..f476358f 100644 --- a/uv.lock +++ b/uv.lock @@ -2,10 +2,22 @@ version = 1 revision = 3 requires-python = ">=3.11, <3.14" resolution-markers = [ - "python_full_version >= '3.13' and platform_machine != 's390x'", - "python_full_version >= '3.13' and platform_machine == 's390x'", - "python_full_version < '3.13' and platform_machine != 's390x'", - "python_full_version < '3.13' and platform_machine == 's390x'", + "python_full_version >= '3.13' and platform_machine != 's390x' and sys_platform == 'win32'", + "python_full_version >= '3.13' and platform_machine != 's390x' and sys_platform == 'darwin'", + "python_full_version >= '3.13' and platform_machine != 's390x' and sys_platform == 'linux'", + "python_full_version >= '3.13' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version >= '3.13' and platform_machine == 's390x' and sys_platform == 'win32'", + "python_full_version >= '3.13' and platform_machine == 's390x' and sys_platform == 'darwin'", + "python_full_version >= '3.13' and platform_machine == 's390x' and sys_platform == 'linux'", + "python_full_version >= '3.13' and platform_machine == 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version < '3.12' and platform_machine != 's390x' and sys_platform == 'win32'", + "python_full_version < '3.12' and platform_machine != 's390x' and sys_platform == 'darwin'", + "python_full_version < '3.12' and platform_machine != 's390x' and sys_platform == 'linux'", + "(python_full_version == '3.12.*' and platform_machine != 's390x' and sys_platform == 'darwin') or (python_full_version == '3.12.*' and platform_machine != 's390x' and sys_platform == 'linux') or (python_full_version == '3.12.*' and platform_machine != 's390x' and sys_platform == 'win32') or (python_full_version < '3.13' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", + "python_full_version < '3.12' and platform_machine == 's390x' and sys_platform == 'win32'", + "python_full_version < '3.12' and platform_machine == 's390x' and sys_platform == 'darwin'", + "python_full_version < '3.12' and platform_machine == 's390x' and sys_platform == 'linux'", + "(python_full_version == '3.12.*' and platform_machine == 's390x' and sys_platform == 'darwin') or (python_full_version == '3.12.*' and platform_machine == 's390x' and sys_platform == 'linux') or (python_full_version == '3.12.*' and platform_machine == 's390x' and sys_platform == 'win32') or (python_full_version < '3.13' and platform_machine == 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", ] [manifest] @@ -69,8 +81,14 @@ name = "bpy" version = "5.0.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.13' and platform_machine != 's390x'", - "python_full_version < '3.13' and platform_machine == 's390x'", + "python_full_version < '3.12' and platform_machine != 's390x' and sys_platform == 'win32'", + "python_full_version < '3.12' and platform_machine != 's390x' and sys_platform == 'darwin'", + "python_full_version < '3.12' and platform_machine != 's390x' and sys_platform == 'linux'", + "(python_full_version == '3.12.*' and platform_machine != 's390x' and sys_platform == 'darwin') or (python_full_version == '3.12.*' and platform_machine != 's390x' and sys_platform == 'linux') or (python_full_version == '3.12.*' and platform_machine != 's390x' and sys_platform == 'win32') or (python_full_version < '3.13' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", + "python_full_version < '3.12' and platform_machine == 's390x' and sys_platform == 'win32'", + "python_full_version < '3.12' and platform_machine == 's390x' and sys_platform == 'darwin'", + "python_full_version < '3.12' and platform_machine == 's390x' and sys_platform == 'linux'", + "(python_full_version == '3.12.*' and platform_machine == 's390x' and sys_platform == 'darwin') or (python_full_version == '3.12.*' and platform_machine == 's390x' and sys_platform == 'linux') or (python_full_version == '3.12.*' and platform_machine == 's390x' and sys_platform == 'win32') or (python_full_version < '3.13' and platform_machine == 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", ] dependencies = [ { name = "cython", marker = "python_full_version < '3.13'" }, @@ -90,8 +108,14 @@ name = "bpy" version = "5.2.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.13' and platform_machine != 's390x'", - "python_full_version >= '3.13' and platform_machine == 's390x'", + "python_full_version >= '3.13' and platform_machine != 's390x' and sys_platform == 'win32'", + "python_full_version >= '3.13' and platform_machine != 's390x' and sys_platform == 'darwin'", + "python_full_version >= '3.13' and platform_machine != 's390x' and sys_platform == 'linux'", + "python_full_version >= '3.13' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version >= '3.13' and platform_machine == 's390x' and sys_platform == 'win32'", + "python_full_version >= '3.13' and platform_machine == 's390x' and sys_platform == 'darwin'", + "python_full_version >= '3.13' and platform_machine == 's390x' and sys_platform == 'linux'", + "python_full_version >= '3.13' and platform_machine == 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ { name = "cattrs", marker = "python_full_version >= '3.13'" }, @@ -216,7 +240,7 @@ wheels = [ [[package]] name = "character-dna-addon" -version = "0.11.3" +version = "0.12.2" source = { editable = "." } dependencies = [ { name = "sentry-sdk" }, @@ -256,6 +280,13 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-dotenv" }, { name = "pytest-html" }, + { name = "pyufbx", version = "0.0.0", source = { path = "src/addons/character_dna/wheels/pyufbx-0.0.0-cp311-cp311-macosx_11_0_arm64.whl" }, marker = "python_full_version < '3.12' and sys_platform == 'darwin'" }, + { name = "pyufbx", version = "0.0.0", source = { path = "src/addons/character_dna/wheels/pyufbx-0.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl" }, marker = "python_full_version < '3.12' and sys_platform == 'linux'" }, + { name = "pyufbx", version = "0.0.0", source = { path = "src/addons/character_dna/wheels/pyufbx-0.0.0-cp311-cp311-win_amd64.whl" }, marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, + { name = "pyufbx", version = "0.0.0", source = { path = "src/addons/character_dna/wheels/pyufbx-0.0.0-cp313-cp313-macosx_11_0_arm64.whl" }, marker = "python_full_version >= '3.13' and sys_platform == 'darwin'" }, + { name = "pyufbx", version = "0.0.0", source = { path = "src/addons/character_dna/wheels/pyufbx-0.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl" }, marker = "python_full_version >= '3.13' and sys_platform == 'linux'" }, + { name = "pyufbx", version = "0.0.0", source = { path = "src/addons/character_dna/wheels/pyufbx-0.0.0-cp313-cp313-win_amd64.whl" }, marker = "python_full_version >= '3.13' and sys_platform == 'win32'" }, + { name = "pyufbx", version = "0.0.7", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version == '3.12.*' and sys_platform == 'darwin') or (python_full_version == '3.12.*' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')" }, { name = "ruff" }, ] @@ -293,6 +324,13 @@ dev = [ { name = "pytest-cov", specifier = ">=6.0.0" }, { name = "pytest-dotenv", specifier = ">=0.5.2" }, { name = "pytest-html", specifier = ">=4.1.1" }, + { name = "pyufbx", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version == '3.12.*' and sys_platform == 'darwin') or (python_full_version >= '3.14' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32') or (python_full_version == '3.12.*' and sys_platform == 'win32') or (python_full_version >= '3.14' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "pyufbx", marker = "python_full_version == '3.11.*' and sys_platform == 'darwin'", path = "src/addons/character_dna/wheels/pyufbx-0.0.0-cp311-cp311-macosx_11_0_arm64.whl" }, + { name = "pyufbx", marker = "python_full_version == '3.13.*' and sys_platform == 'darwin'", path = "src/addons/character_dna/wheels/pyufbx-0.0.0-cp313-cp313-macosx_11_0_arm64.whl" }, + { name = "pyufbx", marker = "python_full_version == '3.11.*' and sys_platform == 'linux'", path = "src/addons/character_dna/wheels/pyufbx-0.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl" }, + { name = "pyufbx", marker = "python_full_version == '3.13.*' and sys_platform == 'linux'", path = "src/addons/character_dna/wheels/pyufbx-0.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl" }, + { name = "pyufbx", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'", path = "src/addons/character_dna/wheels/pyufbx-0.0.0-cp311-cp311-win_amd64.whl" }, + { name = "pyufbx", marker = "python_full_version == '3.13.*' and sys_platform == 'win32'", path = "src/addons/character_dna/wheels/pyufbx-0.0.0-cp313-cp313-win_amd64.whl" }, { name = "ruff", specifier = ">=0.8.0" }, ] @@ -882,8 +920,14 @@ name = "numpy" version = "1.26.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.13' and platform_machine != 's390x'", - "python_full_version < '3.13' and platform_machine == 's390x'", + "python_full_version < '3.12' and platform_machine != 's390x' and sys_platform == 'win32'", + "python_full_version < '3.12' and platform_machine != 's390x' and sys_platform == 'darwin'", + "python_full_version < '3.12' and platform_machine != 's390x' and sys_platform == 'linux'", + "(python_full_version == '3.12.*' and platform_machine != 's390x' and sys_platform == 'darwin') or (python_full_version == '3.12.*' and platform_machine != 's390x' and sys_platform == 'linux') or (python_full_version == '3.12.*' and platform_machine != 's390x' and sys_platform == 'win32') or (python_full_version < '3.13' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", + "python_full_version < '3.12' and platform_machine == 's390x' and sys_platform == 'win32'", + "python_full_version < '3.12' and platform_machine == 's390x' and sys_platform == 'darwin'", + "python_full_version < '3.12' and platform_machine == 's390x' and sys_platform == 'linux'", + "(python_full_version == '3.12.*' and platform_machine == 's390x' and sys_platform == 'darwin') or (python_full_version == '3.12.*' and platform_machine == 's390x' and sys_platform == 'linux') or (python_full_version == '3.12.*' and platform_machine == 's390x' and sys_platform == 'win32') or (python_full_version < '3.13' and platform_machine == 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", ] sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload-time = "2024-02-06T00:26:44.495Z" } wheels = [ @@ -910,8 +954,14 @@ name = "numpy" version = "2.3.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.13' and platform_machine != 's390x'", - "python_full_version >= '3.13' and platform_machine == 's390x'", + "python_full_version >= '3.13' and platform_machine != 's390x' and sys_platform == 'win32'", + "python_full_version >= '3.13' and platform_machine != 's390x' and sys_platform == 'darwin'", + "python_full_version >= '3.13' and platform_machine != 's390x' and sys_platform == 'linux'", + "python_full_version >= '3.13' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version >= '3.13' and platform_machine == 's390x' and sys_platform == 'win32'", + "python_full_version >= '3.13' and platform_machine == 's390x' and sys_platform == 'darwin'", + "python_full_version >= '3.13' and platform_machine == 's390x' and sys_platform == 'linux'", + "python_full_version >= '3.13' and platform_machine == 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/b5/f4/098d2270d52b41f1bd7db9fc288aaa0400cb48c2a3e2af6fa365d9720947/numpy-2.3.4.tar.gz", hash = "sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a", size = 20582187, upload-time = "2025-10-15T16:18:11.77Z" } wheels = [ @@ -1301,6 +1351,130 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "pyufbx" +version = "0.0.0" +source = { path = "src/addons/character_dna/wheels/pyufbx-0.0.0-cp311-cp311-macosx_11_0_arm64.whl" } +resolution-markers = [ + "python_full_version < '3.12' and platform_machine != 's390x' and sys_platform == 'darwin'", + "python_full_version < '3.12' and platform_machine == 's390x' and sys_platform == 'darwin'", +] +dependencies = [ + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' and sys_platform == 'darwin'" }, +] +wheels = [ + { filename = "pyufbx-0.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:42e8c292f77cca0e06ece266025f3ca50d3864242700f2622718b7c8511da002" }, +] + +[package.metadata] +requires-dist = [{ name = "numpy", specifier = ">=1.20.0" }] + +[[package]] +name = "pyufbx" +version = "0.0.0" +source = { path = "src/addons/character_dna/wheels/pyufbx-0.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl" } +resolution-markers = [ + "python_full_version < '3.12' and platform_machine != 's390x' and sys_platform == 'linux'", + "python_full_version < '3.12' and platform_machine == 's390x' and sys_platform == 'linux'", +] +dependencies = [ + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' and sys_platform == 'linux'" }, +] +wheels = [ + { filename = "pyufbx-0.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b2b36e6561e9a658777751254e1a178704e90297dc61d706410d732e07cfa897" }, +] + +[package.metadata] +requires-dist = [{ name = "numpy", specifier = ">=1.20.0" }] + +[[package]] +name = "pyufbx" +version = "0.0.0" +source = { path = "src/addons/character_dna/wheels/pyufbx-0.0.0-cp311-cp311-win_amd64.whl" } +resolution-markers = [ + "python_full_version < '3.12' and platform_machine != 's390x' and sys_platform == 'win32'", + "python_full_version < '3.12' and platform_machine == 's390x' and sys_platform == 'win32'", +] +dependencies = [ + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, +] +wheels = [ + { filename = "pyufbx-0.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:6ff9c5e80fe4633358144d51c4d8e0922b5afd684746c71f07e89bbad99ec7c4" }, +] + +[package.metadata] +requires-dist = [{ name = "numpy", specifier = ">=1.20.0" }] + +[[package]] +name = "pyufbx" +version = "0.0.0" +source = { path = "src/addons/character_dna/wheels/pyufbx-0.0.0-cp313-cp313-macosx_11_0_arm64.whl" } +resolution-markers = [ + "python_full_version >= '3.13' and platform_machine != 's390x' and sys_platform == 'darwin'", + "python_full_version >= '3.13' and platform_machine == 's390x' and sys_platform == 'darwin'", +] +dependencies = [ + { name = "numpy", version = "2.3.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13' and sys_platform == 'darwin'" }, +] +wheels = [ + { filename = "pyufbx-0.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c9cb0fadc24e8eee673a3faf140aef0d5a4ac48044ef7d90f0c4a7a30f40e4c4" }, +] + +[package.metadata] +requires-dist = [{ name = "numpy", specifier = ">=1.20.0" }] + +[[package]] +name = "pyufbx" +version = "0.0.0" +source = { path = "src/addons/character_dna/wheels/pyufbx-0.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl" } +resolution-markers = [ + "python_full_version >= '3.13' and platform_machine != 's390x' and sys_platform == 'linux'", + "python_full_version >= '3.13' and platform_machine == 's390x' and sys_platform == 'linux'", +] +dependencies = [ + { name = "numpy", version = "2.3.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13' and sys_platform == 'linux'" }, +] +wheels = [ + { filename = "pyufbx-0.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af4d56f36826b26e3e6c426c58b368bf0185a738a896d506bc560c0bdef5ca01" }, +] + +[package.metadata] +requires-dist = [{ name = "numpy", specifier = ">=1.20.0" }] + +[[package]] +name = "pyufbx" +version = "0.0.0" +source = { path = "src/addons/character_dna/wheels/pyufbx-0.0.0-cp313-cp313-win_amd64.whl" } +resolution-markers = [ + "python_full_version >= '3.13' and platform_machine != 's390x' and sys_platform == 'win32'", + "python_full_version >= '3.13' and platform_machine == 's390x' and sys_platform == 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.3.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13' and sys_platform == 'win32'" }, +] +wheels = [ + { filename = "pyufbx-0.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4c451af8f5b2468b735fc2e1ade94647e31c8965eebf1d52ba7f4876e574d409" }, +] + +[package.metadata] +requires-dist = [{ name = "numpy", specifier = ">=1.20.0" }] + +[[package]] +name = "pyufbx" +version = "0.0.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version >= '3.13' and platform_machine == 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", + "(python_full_version == '3.12.*' and platform_machine != 's390x' and sys_platform == 'darwin') or (python_full_version == '3.12.*' and platform_machine != 's390x' and sys_platform == 'linux') or (python_full_version == '3.12.*' and platform_machine != 's390x' and sys_platform == 'win32') or (python_full_version < '3.13' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", + "(python_full_version == '3.12.*' and platform_machine == 's390x' and sys_platform == 'darwin') or (python_full_version == '3.12.*' and platform_machine == 's390x' and sys_platform == 'linux') or (python_full_version == '3.12.*' and platform_machine == 's390x' and sys_platform == 'win32') or (python_full_version < '3.13' and platform_machine == 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", +] +dependencies = [ + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "numpy", version = "2.3.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/b2/288f8affd43ceb15d60f47a17e661671eeec297c8c21fb23db8eff54ed3e/pyufbx-0.0.7.tar.gz", hash = "sha256:aabe7cb568f538719c5b7d3efc19f0f578678f99c1e5b3d52689bfc9a15e0e4b", size = 677958, upload-time = "2026-01-26T15:34:30.921Z" } + [[package]] name = "pyyaml" version = "6.0.3"