Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
python-version: ["3.9", "3.10", "3.11", "3.12"]
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ["3.11", "3.13"]

steps:
- uses: actions/checkout@v4
Expand Down
37 changes: 37 additions & 0 deletions .github/workflows/wheels.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Build Wheels

on:
release:
types: [published]
workflow_dispatch:

jobs:
build_wheels:
name: Build wheels on ${{ matrix.os }}
runs-on: ${{ matrix.os }}

strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]

steps:
- uses: actions/checkout@v4

- name: Build wheels
uses: pypa/cibuildwheel@v2.23
env:
# Blender 4.5 ships Python 3.11; Blender 5.x ships Python 3.13.
CIBW_BUILD: "cp311-* cp313-*"
CIBW_SKIP: "*-musllinux_*"
CIBW_ARCHS_MACOS: "x86_64 arm64"
CIBW_ARCHS_LINUX: "x86_64"
CIBW_ARCHS_WINDOWS: "AMD64"
CIBW_TEST_REQUIRES: "pytest numpy"
CIBW_TEST_COMMAND: "pytest {project}/tests -q"

- name: Store wheels
uses: actions/upload-artifact@v4
with:
name: wheels-${{ matrix.os }}
path: ./wheelhouse/*.whl
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,7 @@ coverage.xml
# Test fixtures (large binary FBX files)
tests/fixtures/*.fbx
tests/fixtures/*.glb
# Exception: small fixtures required by the test suite are committed so CI
# (including cibuildwheel test runs) actually executes those tests.
!tests/fixtures/maya_game_sausage_*.fbx
ufbx_cffi_backup/
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.13
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ classifiers = [
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: Implementation :: CPython",
"Topic :: Multimedia :: Graphics :: 3D Modeling",
"Topic :: Software Development :: Libraries :: Python Modules",
Expand Down
8 changes: 7 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

import os
import sys

import numpy as np
from Cython.Build import cythonize
Expand All @@ -29,6 +30,11 @@ def get_long_description():


# Define Cython extension
if sys.platform == "win32":
_optimize_args = ["/O2"]
else:
_optimize_args = ["-O3"]

extensions = [
Extension(
"ufbx._ufbx",
Expand All @@ -44,7 +50,7 @@ def get_long_description():
np.get_include(),
],
define_macros=[("NPY_NO_DEPRECATED_API", "NPY_1_7_API_VERSION")],
extra_compile_args=["-O3"],
extra_compile_args=_optimize_args,
)
]

Expand Down
Binary file not shown.
138 changes: 138 additions & 0 deletions tests/test_animation_evaluation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""Tests for animation evaluation and baking (ufbx 0.23 APIs).

Uses the skinned `maya_game_sausage` wiggle animation from the upstream ufbx
test data (3 joints, ~0.8s clip).
"""

import os
import sys

import numpy as np
import pytest

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

import ufbx


FIXTURE = os.path.join(os.path.dirname(__file__), "fixtures", "maya_game_sausage_7500_binary_wiggle.fbx")

pytestmark = pytest.mark.skipif(not os.path.exists(FIXTURE), reason="animation fixture missing")


@pytest.fixture(scope="module")
def scene():
scene = ufbx.load_file(FIXTURE, ignore_geometry=True)
yield scene
scene.close()


class TestAnimOnlyLoading:
def test_ignore_geometry_load(self, scene):
"""Scene loads with geometry skipped; nodes and stacks remain."""
names = [node.name for node in scene.nodes]
assert "joint1" in names
assert "joint2" in names
assert "joint3" in names

def test_anim_stack_exposes_anim(self, scene):
stacks = scene.anim_stacks
assert len(stacks) >= 1
assert stacks[0].anim is not None

def test_scene_default_anim(self, scene):
assert scene.anim is not None

def test_node_typed_id_roundtrip(self, scene):
for i, node in enumerate(scene.nodes):
assert node.typed_id == i


class TestEvaluateTransform:
def test_evaluate_returns_transform(self, scene):
joint = next(node for node in scene.nodes if node.name == "joint2")
transform = joint.evaluate_transform(0.5)
assert isinstance(transform, ufbx.Transform)
# Rotation should be a valid unit quaternion.
q = transform.rotation
norm = (q.x**2 + q.y**2 + q.z**2 + q.w**2) ** 0.5
assert norm == pytest.approx(1.0, abs=1e-6)

def test_evaluate_changes_over_time(self, scene):
joint = next(node for node in scene.nodes if node.name == "joint2")
q_start = joint.evaluate_transform(0.1).rotation
q_end = joint.evaluate_transform(0.7).rotation
assert abs(q_start.z - q_end.z) > 1e-3

def test_evaluate_with_explicit_anim(self, scene):
anim = scene.anim_stacks[0].anim
joint = next(node for node in scene.nodes if node.name == "joint1")
transform = joint.evaluate_transform(0.25, anim=anim)
assert isinstance(transform, ufbx.Transform)


class TestCurveEvaluate:
def test_curve_evaluate_within_range(self, scene):
curves = scene.anim_curves
assert len(curves) > 0
curve = max(curves, key=lambda c: c.num_keyframes)
value_min = curve.evaluate(curve.min_time)
value_max = curve.evaluate(curve.max_time)
assert curve.min_value - 1e-6 <= value_min <= curve.max_value + 1e-6
assert curve.min_value - 1e-6 <= value_max <= curve.max_value + 1e-6

def test_curve_evaluate_default_value(self, scene):
curve = scene.anim_curves[0]
assert isinstance(curve.evaluate(0.0, 0.0), float)


class TestBakeAnim:
def test_bake_produces_nodes(self, scene):
baked = scene.bake_anim(resample_rate=30.0, trim_start_time=True)
assert isinstance(baked, ufbx.BakedAnim)
assert len(baked.nodes) == 3 # three animated joints
assert baked.playback_duration > 0.5

def test_baked_keys_shapes_and_times(self, scene):
baked = scene.bake_anim(resample_rate=30.0, trim_start_time=True)
for baked_node in baked.nodes:
num_r = len(baked_node.rotation_times)
assert baked_node.rotation_values.shape == (num_r, 4)
assert baked_node.translation_values.shape == (len(baked_node.translation_times), 3)
assert baked_node.scale_values.shape == (len(baked_node.scale_times), 3)
# Times must start at ~0 (trimmed) and be strictly increasing.
assert baked_node.rotation_times[0] == pytest.approx(0.0, abs=1e-9)
assert np.all(np.diff(baked_node.rotation_times) > 0)

def test_baked_rotations_are_unit_quaternions(self, scene):
baked = scene.bake_anim(resample_rate=30.0)
for baked_node in baked.nodes:
norms = np.linalg.norm(baked_node.rotation_values, axis=1)
np.testing.assert_allclose(norms, 1.0, atol=1e-6)

def test_baked_node_maps_to_scene_node(self, scene):
baked = scene.bake_anim()
names = {scene.nodes[baked_node.typed_id].name for baked_node in baked.nodes}
assert names == {"joint1", "joint2", "joint3"}

def test_bake_matches_evaluate_transform(self, scene):
"""Baked keys must agree with direct per-time evaluation."""
baked = scene.bake_anim(resample_rate=30.0)
baked_node = next(b for b in baked.nodes if scene.nodes[b.typed_id].name == "joint2")
node = scene.nodes[baked_node.typed_id]
for key_index in (0, len(baked_node.rotation_times) // 2, len(baked_node.rotation_times) - 1):
t = baked_node.rotation_times[key_index]
q_eval = node.evaluate_transform(float(t)).rotation
q_baked = baked_node.rotation_values[key_index]
dot = abs(
q_baked[0] * q_eval.x + q_baked[1] * q_eval.y + q_baked[2] * q_eval.z + q_baked[3] * q_eval.w
)
assert dot == pytest.approx(1.0, abs=1e-6)

def test_baked_arrays_survive_scene_close(self):
scene = ufbx.load_file(FIXTURE, ignore_geometry=True)
baked = scene.bake_anim(resample_rate=30.0)
rotations = baked.nodes[0].rotation_values
scene.close()
# Arrays were copied out; must remain readable after close.
assert np.isfinite(rotations).all()
Loading
Loading