Source code for scfile.content.models.scene

"""Scene structures."""

from dataclasses import dataclass, field
from typing import assert_never

import numpy as np

from .enums import AnimationRotation, AnimationTranslation, Feature, SkeletonSpace
from .matrices import euler_to_quat
from .mesh import ModelMesh
from .types import (
    AnimationRotations,
    AnimationTimes,
    AnimationTranslations,
    EulerAngles,
    InverseBindMatrices,
    MorphWeights,
    Quaternion,
    Vector3D,
)


ROOT_BONE_ID = -1
"""Default parent ID for root bones."""


[docs] @dataclass class SkeletonBone: """Bone with transform data.""" id: int = 0 name: str = "bone" parent_id: int = ROOT_BONE_ID position: Vector3D = field(default_factory=lambda: np.zeros(3, dtype=np.float32)) rotation: EulerAngles = field(default_factory=lambda: np.zeros(3, dtype=np.float32)) tail: Vector3D = field(default_factory=lambda: np.zeros(3, dtype=np.float32)) @property def is_root(self) -> bool: return self.parent_id == ROOT_BONE_ID @property def quaternion(self) -> Quaternion: return euler_to_quat(self.rotation) @property def slug(self) -> str: return "".join(ch for ch in self.name.lower() if ch.isalnum())
[docs] @dataclass class ModelSkeleton: """Skeleton bones container.""" bones: list[SkeletonBone] = field(default_factory=list) space: SkeletonSpace = SkeletonSpace.GLOBAL @property def roots(self) -> list[SkeletonBone]: return [bone for bone in self.bones if bone.is_root]
[docs] @dataclass class AnimationClip: """Keyframed bone and morph animation clip.""" name: str = "clip" frames: int = 0 rate: float = 0.33 translations: AnimationTranslations = field(default_factory=lambda: np.zeros((0, 3), dtype=np.float32)) rotations: AnimationRotations = field(default_factory=lambda: np.zeros((0, 4), dtype=np.float32)) morph_weights: MorphWeights = field(default_factory=lambda: np.zeros((0, 0), dtype=np.float32)) @property def times(self) -> AnimationTimes: return np.arange(self.frames, dtype=np.float32) * self.rate
[docs] @dataclass class ModelAnimation: """Animation clips container.""" clips: list[AnimationClip] = field(default_factory=list) morph_channels: list[str] = field(default_factory=list) translation: AnimationTranslation = AnimationTranslation.DELTA rotation: AnimationRotation = AnimationRotation.QUATERNION
[docs] @dataclass class SceneScales: """Scale multipliers for scene data.""" position: float = 1.0 uv: float = 1.0 uv2: float = 1.0
[docs] @dataclass class ModelSkin: """Bind matrices used by meshes in an assembled scene.""" bind_matrices: InverseBindMatrices
[docs] @dataclass class ModelScene: """Container for meshes, skeleton, and animation.""" scale: SceneScales = field(default_factory=SceneScales) meshes: list[ModelMesh] = field(default_factory=list) skins: list[ModelSkin] = field(default_factory=list) skeleton: ModelSkeleton = field(default_factory=ModelSkeleton) animation: ModelAnimation = field(default_factory=ModelAnimation)
[docs] def has( self, feature: Feature, ) -> bool: """Return whether scene contains a feature.""" match feature: case Feature.UV: return any(mesh.uv1.size for mesh in self.meshes) case Feature.UV2: return any(mesh.uv2.size for mesh in self.meshes) case Feature.NORMALS: return any(mesh.normals.size for mesh in self.meshes) case Feature.TANGENTS: return any(mesh.tangents.size for mesh in self.meshes) case Feature.COLORS: return any(mesh.colors.size for mesh in self.meshes) case Feature.SKELETON: return bool(self.skeleton.bones) case Feature.BLEND_SHAPES: return any(mesh.blend_shapes for mesh in self.meshes) case Feature.ANIMATION: return any(self.has(member) for member in feature.members) case Feature.BONE_ANIMATION: return any(clip.translations.size and clip.rotations.size for clip in self.animation.clips) case Feature.MORPH_ANIMATION: return any(clip.morph_weights.size for clip in self.animation.clips) case _: assert_never(feature)
@property def total_vertices(self) -> int: return sum(len(mesh.vertices) for mesh in self.meshes) @property def total_polygons(self) -> int: return sum(len(mesh.polygons) for mesh in self.meshes)