"""Scene transformation functions."""
from __future__ import annotations
from collections.abc import Callable
from copy import replace
from typing import TYPE_CHECKING
import numpy as np
from scfile.exceptions import AnimationError
from .enums import AnimationTranslation, LinkSpace, SkeletonSpace, UVOrigin, UVSign
from .matrices import create_transform_matrix
from .mesh import ModelMesh
from .scene import ROOT_BONE_ID, AnimationClip, ModelScene, ModelSkeleton, ModelSkin, SkeletonBone
from .types import BindPose, InverseBindMatrices, TransformMatrix
if TYPE_CHECKING:
from scfile.content import ModelContent
type SceneTransform = Callable[[ModelScene], ModelScene]
type ModelTransform = Callable[["ModelContent"], "ModelContent"]
type AnimationTransform = Callable[[ModelScene, ModelScene], ModelScene]
[docs]
def unique_names(scene: ModelScene) -> ModelScene:
"""Ensure all meshes have unique names."""
seen_names: set[str] = set()
meshes: list[ModelMesh] = []
for mesh in scene.meshes:
name = mesh.name or "noname"
base_name, count = name, 2
unique_name = f"{base_name}"
while unique_name in seen_names:
unique_name = f"{base_name}_{count}"
count += 1
seen_names.add(unique_name)
meshes.append(replace(mesh, name=unique_name))
return replace(scene, meshes=meshes)
[docs]
def flip_uv(scene: ModelScene) -> ModelScene:
"""Flip V axis (TOP_LEFT β BOTTOM_LEFT)."""
meshes: list[ModelMesh] = []
for mesh in scene.meshes:
if mesh.uv_origin == UVOrigin.BOTTOM_LEFT and mesh.uv_sign == UVSign.POSITIVE:
meshes.append(mesh)
continue
new_mesh = replace(mesh)
new_mesh.uv1 = mesh.uv1.copy()
new_mesh.uv2 = mesh.uv2.copy()
new_mesh.uv1[:, 1] = 1.0 - new_mesh.uv1[:, 1]
new_mesh.uv2[:, 1] = 1.0 - new_mesh.uv2[:, 1]
new_mesh.uv_origin = UVOrigin.BOTTOM_LEFT
new_mesh.uv_sign = UVSign.POSITIVE
meshes.append(new_mesh)
return replace(scene, meshes=meshes)
[docs]
def invert_uv(scene: ModelScene) -> ModelScene:
"""Invert V axis sign (POSITIVE β NEGATIVE)."""
meshes: list[ModelMesh] = []
for mesh in scene.meshes:
if mesh.uv_sign == UVSign.NEGATIVE:
meshes.append(mesh)
continue
new_mesh = replace(mesh)
new_mesh.uv1 = mesh.uv1.copy()
new_mesh.uv2 = mesh.uv2.copy()
new_mesh.uv1[:, 1] *= -1.0
new_mesh.uv2[:, 1] *= -1.0
new_mesh.uv_sign = UVSign.NEGATIVE
meshes.append(new_mesh)
return replace(scene, meshes=meshes)
[docs]
def inverse_bind_matrices(
skeleton: ModelSkeleton,
*,
transpose: bool = False,
) -> InverseBindMatrices:
"""Compute inverse bind matrices for a valid skeleton."""
matrices = [np.linalg.inv(matrix) for matrix in global_transforms(skeleton)]
if not matrices:
return np.empty((0, 4, 4), dtype=np.float32)
if transpose:
matrices = [matrix.T for matrix in matrices]
return np.array(matrices, dtype=np.float32)
[docs]
def skeleton_to_local(scene: ModelScene) -> ModelScene:
"""Convert bone positions (GLOBAL β LOCAL)."""
if scene.skeleton.space == SkeletonSpace.LOCAL:
return scene
new_bones: list[SkeletonBone] = []
for bone in scene.skeleton.bones:
new_bone = replace(bone)
new_bone.position = bone.position.copy()
if not bone.is_root:
new_bone.position -= scene.skeleton.bones[bone.parent_id].position
new_bones.append(new_bone)
new_skeleton = replace(scene.skeleton, bones=new_bones, space=SkeletonSpace.LOCAL)
return replace(scene, skeleton=new_skeleton)
[docs]
def animation_to_absolute(scene: ModelScene) -> ModelScene:
"""Add rest pose positions to animation deltas (DELTA β ABSOLUTE)."""
if scene.animation.translation == AnimationTranslation.ABSOLUTE:
return scene
skeleton = scene.skeleton
positions = np.array([bone.position for bone in skeleton.bones], dtype=np.float32)
new_clips: list[AnimationClip] = []
for clip in scene.animation.clips:
if not clip.translations.size:
new_clips.append(clip)
continue
new_translations = clip.translations.copy()
new_translations += positions[np.newaxis, :, :]
new_clips.append(replace(clip, translations=new_translations))
new_animation = replace(
scene.animation,
clips=new_clips,
translation=AnimationTranslation.ABSOLUTE,
)
return replace(scene, animation=new_animation)
[docs]
def apply_fp_animation(animation: ModelScene, *models: ModelScene) -> ModelScene:
"""Combine model geometry with animation rig."""
if not animation.animation.clips:
raise AnimationError("Animation contains no clips.")
if not models:
raise AnimationError("No models provided.")
target_ids = {bone.name: bone.id for bone in animation.skeleton.bones}
if len(target_ids) != len(animation.skeleton.bones):
raise AnimationError("Animation skeleton contains duplicate bone names.")
meshes = list(animation.meshes)
for model in models:
for mesh in model.meshes:
used_ids = {
int(bone_id) for bone_id, weight in zip(mesh.links_ids.flat, mesh.links_weights.flat) if weight > 0.0
}
for source_id in used_ids:
if source_id >= len(model.skeleton.bones):
raise AnimationError(f"Model references unknown bone {source_id}.")
name = model.skeleton.bones[source_id].name
if name not in target_ids:
raise AnimationError(f"Animation skeleton has no bone '{name}'.")
source_ids = np.zeros(len(model.skeleton.bones), dtype=np.uint8)
for bone in model.skeleton.bones:
if bone.name in target_ids:
source_ids[bone.id] = target_ids[bone.name]
for mesh in model.meshes:
new_mesh = replace(mesh)
if mesh.max_influences:
new_mesh.links_ids = source_ids[mesh.links_ids]
new_mesh.link_space = LinkSpace.GLOBAL
meshes.append(new_mesh)
return replace(animation, meshes=meshes)
[docs]
def extend_fp_skeleton(animation: ModelScene, *models: ModelScene) -> ModelScene:
"""Add model bones missing from a first-person animation skeleton."""
target_bones = list(animation.skeleton.bones)
target_ids = {bone.name: bone.id for bone in target_bones}
original_count = len(target_bones)
if len(target_ids) != original_count:
raise AnimationError("Animation skeleton contains duplicate bone names.")
for model in models:
source_bones = model.skeleton.bones
source_names = {bone.name for bone in source_bones}
if len(source_names) != len(source_bones):
raise AnimationError("Model skeleton contains duplicate bone names.")
if model.skeleton.space != animation.skeleton.space:
raise AnimationError("Animation and model skeletons use different coordinate spaces.")
resolving: set[int] = set()
def add_bone(source_id: int) -> int:
bone = source_bones[source_id]
if bone.name in target_ids:
return target_ids[bone.name]
if source_id in resolving:
raise AnimationError("Model skeleton contains a bone hierarchy cycle.")
resolving.add(source_id)
parent_id = ROOT_BONE_ID if bone.is_root else add_bone(bone.parent_id)
resolving.remove(source_id)
target_id = len(target_bones)
if target_id > np.iinfo(np.uint8).max:
raise AnimationError("Combined skeleton contains more than 256 bones.")
target_bones.append(replace(bone, id=target_id, parent_id=parent_id))
target_ids[bone.name] = target_id
return target_id
used_ids = {
int(bone_id)
for mesh in model.meshes
for bone_id, weight in zip(mesh.links_ids.flat, mesh.links_weights.flat)
if weight > 0.0
}
for source_id in used_ids:
if source_id >= len(source_bones):
raise AnimationError(f"Model references unknown bone {source_id}.")
add_bone(source_id)
added = len(target_bones) - original_count
if not added:
return animation
skeleton = replace(animation.skeleton, bones=target_bones)
absolute_positions: np.ndarray | None = None
if animation.animation.translation == AnimationTranslation.ABSOLUTE:
local = skeleton_to_local(replace(animation, skeleton=skeleton)).skeleton
absolute_positions = np.array([bone.position for bone in local.bones[original_count:]], dtype=np.float32)
clips: list[AnimationClip] = []
for clip in animation.animation.clips:
translations = clip.translations
if translations.size:
if translations.shape != (clip.frames, original_count, 3):
raise AnimationError(f"Animation clip '{clip.name}' has invalid translations.")
extra = np.zeros((clip.frames, added, 3), dtype=translations.dtype)
if absolute_positions is not None:
extra[:] = absolute_positions
translations = np.concatenate((translations, extra), axis=1)
rotations = clip.rotations
if rotations.size:
if rotations.shape != (clip.frames, original_count, 4):
raise AnimationError(f"Animation clip '{clip.name}' has invalid rotations.")
extra = np.zeros((clip.frames, added, 4), dtype=rotations.dtype)
extra[:, :, 3] = 1.0
rotations = np.concatenate((rotations, extra), axis=1)
clips.append(replace(clip, translations=translations, rotations=rotations))
model_animation = replace(animation.animation, clips=clips)
return replace(animation, skeleton=skeleton, animation=model_animation)
[docs]
def apply_skins(scene: ModelScene, animation: ModelScene, *models: ModelScene) -> ModelScene:
"""Apply source bind poses to the assembled scene."""
target_bones = animation.skeleton.bones
target_ids = {bone.name: bone.id for bone in target_bones}
target_scene = skeleton_to_local(animation)
target_bind = inverse_bind_matrices(target_scene.skeleton)
skins: list[InverseBindMatrices] = []
mesh_skins: list[int | None] = []
# Preserve source bind poses against the shared animation skeleton
for model in (animation, *models):
skinned = any(mesh.max_influences for mesh in model.meshes)
skin_index = len(skins) if skinned else None
if skinned:
bind = target_bind.copy()
source_scene = skeleton_to_local(model)
source_bind = inverse_bind_matrices(source_scene.skeleton)
# Keep target matrices for bones absent from this model
for bone in model.skeleton.bones:
if bone.name in target_ids:
bind[target_ids[bone.name]] = source_bind[bone.id]
skins.append(bind)
mesh_skins.extend(skin_index if mesh.max_influences else None for mesh in model.meshes)
meshes = [replace(mesh, skin=skin) for mesh, skin in zip(scene.meshes, mesh_skins)]
return replace(scene, meshes=meshes, skins=[ModelSkin(bind) for bind in skins])
[docs]
def apply_fp_models(animation: ModelScene, *models: ModelScene) -> ModelScene:
"""Apply first-person models to the animation scene."""
animation = extend_fp_skeleton(animation, *models)
scene = apply_fp_animation(animation, *models)
return apply_skins(scene, animation, *models)
[docs]
def apply_skeletal_animation(animation: ModelScene, model: ModelScene) -> ModelScene:
"""Apply index-mapped skeletal animation clips to model."""
if not animation.animation.clips:
raise AnimationError("Animation contains no clips.")
clips = animation.animation.clips
clip_bones = clips[0].translations.shape[1]
model_bones = len(model.skeleton.bones)
if clip_bones != model_bones:
raise AnimationError(f"Animation has {clip_bones} bones, model has {model_bones}.")
return replace(model, animation=replace(animation.animation))
[docs]
def apply_morph_animation(animation: ModelScene, model: ModelScene) -> ModelScene:
"""Apply morph animation clips to compatible model blend shapes."""
clips = animation.animation.clips
channels = animation.animation.morph_channels
if not clips or not any(clip.morph_weights.size for clip in clips):
raise AnimationError("Animation contains no morph clips.")
if len(set(channels)) != len(channels):
raise AnimationError("Animation contains duplicate morph channel names.")
mapped = {shape.channel for mesh in model.meshes for shape in mesh.blend_shapes if shape.channel is not None}
if not mapped:
raise AnimationError("Model contains no mapped blend shapes.")
if not mapped.intersection(channels):
raise AnimationError("Animation has no channels compatible with model blend shapes.")
return replace(model, animation=replace(animation.animation))