From 99142be1955b737e9cdfb5c44830b60d2cc9b72b Mon Sep 17 00:00:00 2001 From: Jack Yao Date: Sun, 26 Jul 2026 19:21:59 -0500 Subject: [PATCH 1/3] Animation and python 3.13 support --- .github/workflows/test.yml | 4 +- .github/workflows/wheels.yml | 37 ++++ .python-version | 1 + pyproject.toml | 1 + setup.py | 8 +- tests/test_animation_evaluation.py | 138 +++++++++++++++ ufbx-c/ufbx.c | 228 ++++++++++++++++++------- ufbx-c/ufbx.h | 19 ++- ufbx/__init__.py | 4 + ufbx/__init__.pyi | 49 +++++- ufbx/_ufbx.pyx | 260 ++++++++++++++++++++++++++++- ufbx/src/ufbx_wrapper.c | 181 ++++++++++++++++++++ ufbx/src/ufbx_wrapper.h | 42 +++++ 13 files changed, 900 insertions(+), 72 deletions(-) create mode 100644 .github/workflows/wheels.yml create mode 100644 .python-version create mode 100644 tests/test_animation_evaluation.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eb9a8a9..997251b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml new file mode 100644 index 0000000..bf84c1d --- /dev/null +++ b/.github/workflows/wheels.yml @@ -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 diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/pyproject.toml b/pyproject.toml index dd20629..3d8a362 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/setup.py b/setup.py index 5e15903..4935e0d 100644 --- a/setup.py +++ b/setup.py @@ -3,6 +3,7 @@ """ import os +import sys import numpy as np from Cython.Build import cythonize @@ -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", @@ -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, ) ] diff --git a/tests/test_animation_evaluation.py b/tests/test_animation_evaluation.py new file mode 100644 index 0000000..46c33d0 --- /dev/null +++ b/tests/test_animation_evaluation.py @@ -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() diff --git a/ufbx-c/ufbx.c b/ufbx-c/ufbx.c index 3b67f3d..70dce03 100644 --- a/ufbx-c/ufbx.c +++ b/ufbx-c/ufbx.c @@ -874,7 +874,7 @@ enum { UFBX_MAXIMUM_ALIGNMENT = sizeof(void*) > 8 ? sizeof(void*) : 8 }; // -- Version -#define UFBX_SOURCE_VERSION ufbx_pack_version(0, 21, 2) +#define UFBX_SOURCE_VERSION ufbx_pack_version(0, 23, 0) ufbx_abi_data_def const uint32_t ufbx_source_version = UFBX_SOURCE_VERSION; ufbx_static_assert(source_header_version, UFBX_SOURCE_VERSION/1000u == UFBX_HEADER_VERSION/1000u); @@ -5417,6 +5417,7 @@ static const char ufbxi_KeyAttrRefCount[] = "KeyAttrRefCount"; static const char ufbxi_KeyCount[] = "KeyCount"; static const char ufbxi_KeyTime[] = "KeyTime"; static const char ufbxi_KeyValueFloat[] = "KeyValueFloat"; +static const char ufbxi_KeyVer[] = "KeyVer"; static const char ufbxi_Key[] = "Key"; static const char ufbxi_KnotVectorU[] = "KnotVectorU"; static const char ufbxi_KnotVectorV[] = "KnotVectorV"; @@ -5514,9 +5515,11 @@ static const char ufbxi_RightCamera[] = "RightCamera"; static const char ufbxi_RootNode[] = "RootNode"; static const char ufbxi_Root[] = "Root"; static const char ufbxi_RotationAccumulationMode[] = "RotationAccumulationMode"; +static const char ufbxi_RotationActive[] = "RotationActive"; static const char ufbxi_RotationOffset[] = "RotationOffset"; static const char ufbxi_RotationOrder[] = "RotationOrder"; static const char ufbxi_RotationPivot[] = "RotationPivot"; +static const char ufbxi_RotationSpaceForLimitOnly[] = "RotationSpaceForLimitOnly"; static const char ufbxi_Rotation[] = "Rotation"; static const char ufbxi_S[] = "S\0\0"; static const char ufbxi_ScaleAccumulationMode[] = "ScaleAccumulationMode"; @@ -5719,6 +5722,7 @@ static const ufbx_string ufbxi_strings[] = { { ufbxi_KeyCount, 8 }, { ufbxi_KeyTime, 7 }, { ufbxi_KeyValueFloat, 13 }, + { ufbxi_KeyVer, 6 }, { ufbxi_KnotVector, 10 }, { ufbxi_KnotVectorU, 11 }, { ufbxi_KnotVectorV, 11 }, @@ -5816,9 +5820,11 @@ static const ufbx_string ufbxi_strings[] = { { ufbxi_RootNode, 8 }, { ufbxi_Rotation, 8 }, { ufbxi_RotationAccumulationMode, 24 }, + { ufbxi_RotationActive, 14 }, { ufbxi_RotationOffset, 14 }, { ufbxi_RotationOrder, 13 }, { ufbxi_RotationPivot, 13 }, + { ufbxi_RotationSpaceForLimitOnly, 25 }, { ufbxi_S, 1 }, { ufbxi_ScaleAccumulationMode, 21 }, { ufbxi_Scaling, 7 }, @@ -9870,7 +9876,16 @@ ufbxi_nodiscard ufbxi_noinline static int ufbxi_ascii_next_token(ufbxi_context * c = ufbxi_ascii_next(uc); } // Skip closing quote - ufbxi_ascii_next(uc); + char next = ufbxi_ascii_next(uc); + + // Check if the next character is ':', in some legacy FBX files we have names with + // spaces, like `"Transport Tool Settings": { ... }` + if (next == ':') { + token->value.name_len = token->str_len; + token->type = UFBXI_ASCII_NAME; + ufbxi_ascii_next(uc); + } + } else { // Single character token token->type = c; @@ -11576,6 +11591,16 @@ static ufbxi_forceinline bool ufbxi_is_quat_identity(ufbx_quat v) return (v.x == 0.0) & (v.y == 0.0) & (v.z == 0.0) & (v.w == 1.0); } +static ufbxi_unused ufbxi_forceinline bool ufbxi_is_vec3_equal(ufbx_vec3 a, ufbx_vec3 b) +{ + return (a.x == b.x) & (a.y == b.y) & (a.z == b.z); +} + +static ufbxi_unused ufbxi_forceinline bool ufbxi_is_quat_equal(ufbx_quat a, ufbx_quat b) +{ + return (a.x == b.x) & (a.y == b.y) & (a.z == b.z) & (a.w == b.w); +} + static ufbxi_noinline bool ufbxi_is_transform_identity(const ufbx_transform *t) { return (bool)((int)ufbxi_is_vec3_zero(t->translation) & (int)ufbxi_is_quat_identity(t->rotation) & (int)ufbxi_is_vec3_one(t->scale)); @@ -12032,7 +12057,7 @@ static bool ufbxi_match_version_string(const char *fmt, ufbx_string str, uint32_ } if (pos >= str.length) return false; pos++; - } else if (c == '/' || c == '.' || c == '(' || c == ')') { + } else if (c == '/' || c == '.' || c == '(' || c == ')' || c == '_') { if (pos >= str.length) return false; if (str.data[pos] != c) return false; pos++; @@ -12081,6 +12106,9 @@ ufbxi_nodiscard ufbxi_noinline static int ufbxi_match_exporter(ufbxi_context *uc } else if (ufbxi_match_version_string("motionbuilder/mocap/online version ?.?", creator, version)) { uc->exporter = UFBX_EXPORTER_MOTION_BUILDER; uc->exporter_version = ufbx_pack_version(version[0], version[1], 0); + } else if (ufbxi_match_version_string("ufbx_write", creator, version)) { + uc->exporter = UFBX_EXPORTER_UFBX_WRITE; + uc->exporter_version = ufbx_pack_version(0, 0, 1); } uc->scene.metadata.exporter = uc->exporter; @@ -14808,6 +14836,16 @@ ufbxi_nodiscard ufbxi_noinline static int ufbxi_read_constraint(ufbxi_context *u ufbxi_nodiscard ufbxi_noinline static int ufbxi_read_synthetic_attribute(ufbxi_context *uc, ufbxi_node *node, ufbxi_element_info *info, ufbx_string type_str, const char *sub_type, const char *super_type) { + // Some legacy (version 6000) files store mesh nodes without any `sub_type` + // There seems to be no robust indicator, so detect it from `Vertices` and `PolygonVertexIndex` + if (sub_type == ufbxi_empty_char) { + ufbxi_node *node_vertices = ufbxi_find_child(node, ufbxi_Vertices); + ufbxi_node *node_indices = ufbxi_find_child(node, ufbxi_PolygonVertexIndex); + if (node_vertices && node_indices) { + sub_type = ufbxi_Mesh; + } + } + if ((sub_type == ufbxi_empty_char || sub_type == ufbxi_Model) && type_str.data == ufbxi_Model) { // Plain model return 1; @@ -15301,6 +15339,18 @@ ufbxi_nodiscard ufbxi_noinline static int ufbxi_read_take_anim_channel(ufbxi_con if (uc->opts.ignore_animation) return 1; + int32_t key_ver = 0; + ufbxi_ignore(ufbxi_find_val1(node, ufbxi_KeyVer, "I", &key_ver)); + if (key_ver <= 0) { + if (uc->version < 5000) { + key_ver = 4003; + } else if (uc->version < 6000) { + key_ver = 4004; + } else { + key_ver = 4005; + } + } + size_t num_keys = 0; ufbxi_check(ufbxi_find_val1(node, ufbxi_KeyCount, "Z", &num_keys)); curve->keyframes.data = ufbxi_push(&uc->result, ufbx_keyframe, num_keys); @@ -15364,14 +15414,21 @@ ufbxi_nodiscard ufbxi_noinline static int ufbxi_read_take_anim_channel(ufbxi_con slope_right = (float)data[0]; next_slope_left = (float)data[1]; data += 2; + // TODO: This looks very suspicious, but we have observed files with + // KeyVer=4002 -> followed by 'n', then next key + // KeyVer=4003 -> no weight mode, directly followed by key + // KeyVer=4004 -> followed by 'n', then next key + if (key_ver == 4003) { + num_weights = 0; + } } else if (slope_mode == 'a') { // Parameterless slope mode 'a' seems to appear in baked animations. Let's just assume // automatic tangents for now as they're the least likely to break with // objectionable artifacts. We need to defer the automatic tangent resolve // until we have read the next time/value. - // TODO: Solve what this is more thoroughly + // TODO: Solve what this is more thoroughly, using auto slope for now to reduce artifacts auto_slope = true; - if (uc->version == 5000) { + if (key_ver <= 4004) { num_weights = 0; } } else if (slope_mode == 'p') { @@ -15379,15 +15436,45 @@ ufbxi_nodiscard ufbxi_noinline static int ufbxi_read_take_anim_channel(ufbxi_con // Also it seems to have _two_ trailing weights values, currently observed: // `n,n` and `a,X,Y,n`... // Ignore unknown values for now + // TODO: Solve what this is more thoroughly, using auto slope for now to reduce artifacts + auto_slope = true; ufbxi_check(data_end - data >= 2); data += 2; - num_weights = 2; + if (key_ver <= 4004) { + num_weights = 1; + } else { + num_weights = 2; + } + } else if (slope_mode == 'q') { + // TODO: What is this mode? It seems to have negative values sometimes? + // Also it seems to have _two_ trailing weights values, currently observed: + // `d,d` and `n`... + // Ignore unknown values for now + // TODO: This has only been observed with KeyVer=4003/4005, it might have two weights in 4004 + // TODO: Solve what this is more thoroughly, using auto slope for now to reduce artifacts + auto_slope = true; + ufbxi_check(data_end - data >= 2); + data += 2; + if (key_ver <= 4004) { + num_weights = 1; + } else { + num_weights = 2; + } } else if (slope_mode == 't') { // TODO: What is this mode? It seems that it does not have any weights and the // third value seems _tiny_ (around 1e-30?) + // TODO: This looks like simple TCB parameters, currently falling back to auto. + auto_slope = true; ufbxi_check(data_end - data >= 3); data += 3; num_weights = 0; + } else if (slope_mode == 'd') { + // TODO: What is this mode? It has a single parameter (currently observed `0`) + // and a single weight. + // TODO: Solve what this is more thoroughly, using auto slope for now to reduce artifacts + auto_slope = true; + ufbxi_check(data_end - data >= 1); + data += 1; } else { ufbxi_fail("Unknown slope mode"); } @@ -15428,9 +15515,13 @@ ufbxi_nodiscard ufbxi_noinline static int ufbxi_read_take_anim_channel(ufbxi_con key->interpolation = UFBX_INTERPOLATION_LINEAR; } else if (mode == 'C') { // Constant interpolation: Single parameter (use prev/next) - ufbxi_check(data_end - data >= 1); - key->interpolation = ufbxi_double_to_char(data[0]) == 'n' ? UFBX_INTERPOLATION_CONSTANT_NEXT : UFBX_INTERPOLATION_CONSTANT_PREV; - data += 1; + if (key_ver >= 4004) { + ufbxi_check(data_end - data >= 1); + key->interpolation = ufbxi_double_to_char(data[0]) == 'n' ? UFBX_INTERPOLATION_CONSTANT_NEXT : UFBX_INTERPOLATION_CONSTANT_PREV; + data += 1; + } else { + key->interpolation = UFBX_INTERPOLATION_CONSTANT_PREV; + } } else { ufbxi_fail("Unknown key mode"); } @@ -19392,6 +19483,7 @@ static const ufbxi_shader_mapping ufbxi_obj_fbx_mapping[] = { { UFBX_MATERIAL_FBX_NORMAL_MAP, 0, 0, ufbxi_mat_string("norm") }, { UFBX_MATERIAL_FBX_DISPLACEMENT, 0, 0, ufbxi_mat_string("disp") }, { UFBX_MATERIAL_FBX_BUMP, 0, 0, ufbxi_mat_string("bump") }, + { UFBX_MATERIAL_FBX_BUMP, 0, 0, ufbxi_mat_string("Bump") }, }; static const ufbxi_shader_mapping ufbxi_fbx_lambert_shader_pbr_mapping[] = { @@ -19737,6 +19829,7 @@ static const ufbxi_shader_mapping ufbxi_obj_pbr_mapping[] = { { UFBX_MATERIAL_PBR_TRANSMISSION_COLOR, UFBXI_SHADER_MAPPING_DEFAULT_W_1|UFBXI_SHADER_MAPPING_WIDEN_TO_RGB, 0, ufbxi_mat_string("Tf") }, { UFBX_MATERIAL_PBR_DISPLACEMENT_MAP, 0, 0, ufbxi_mat_string("disp") }, { UFBX_MATERIAL_PBR_NORMAL_MAP, 0, 0, ufbxi_mat_string("bump") }, + { UFBX_MATERIAL_PBR_NORMAL_MAP, 0, 0, ufbxi_mat_string("Bump") }, { UFBX_MATERIAL_PBR_NORMAL_MAP, 0, 0, ufbxi_mat_string("norm") }, { UFBX_MATERIAL_PBR_SHEEN_COLOR, UFBXI_SHADER_MAPPING_DEFAULT_W_1|UFBXI_SHADER_MAPPING_WIDEN_TO_RGB, 0, ufbxi_mat_string("Ps") }, { UFBX_MATERIAL_PBR_COAT_FACTOR, 0, 0, ufbxi_mat_string("Pc") }, @@ -22690,6 +22783,56 @@ ufbxi_noinline static ufbx_transform ufbxi_get_geometry_transform(const ufbx_pro return t; } +ufbxi_noinline static ufbx_quat ufbxi_get_rotation(const ufbx_props *props, ufbx_rotation_order order, const ufbx_node *node) +{ + ufbx_vec3 rotation = ufbxi_find_vec3(props, ufbxi_Lcl_Rotation, 0.0f, 0.0f, 0.0f); + ufbx_vec3 pre_rotation = ufbxi_find_vec3(props, ufbxi_PreRotation, 0.0f, 0.0f, 0.0f); + ufbx_vec3 post_rotation = ufbxi_find_vec3(props, ufbxi_PostRotation, 0.0f, 0.0f, 0.0f); + + ufbx_transform t = { { 0,0,0 }, { 0,0,0,1 }, { 1,1,1 }}; + + if (node->has_adjust_transform) { + ufbxi_mul_rotate_quat(&t, node->adjust_post_rotation); + } + + if (node->use_rotation_space) { + ufbxi_mul_inv_rotate(&t, post_rotation, UFBX_ROTATION_ORDER_XYZ); + ufbxi_mul_rotate(&t, rotation, order); + ufbxi_mul_rotate(&t, pre_rotation, UFBX_ROTATION_ORDER_XYZ); + } else { + ufbxi_mul_rotate(&t, rotation, UFBX_ROTATION_ORDER_XYZ); + } + + if (node->has_adjust_transform) { + ufbxi_mul_rotate_quat(&t, node->adjust_pre_rotation); + } + + if (node->adjust_mirror_axis) { + ufbxi_mirror_rotation(&t.rotation, node->adjust_mirror_axis); + } + + return t.rotation; +} + +ufbxi_noinline static ufbx_vec3 ufbxi_get_scale(const ufbx_props *props, const ufbx_node *node) +{ + ufbx_vec3 scaling = ufbxi_find_vec3(props, ufbxi_Lcl_Scaling, 1.0f, 1.0f, 1.0f); + + ufbx_transform t = { { 0,0,0 }, { 0,0,0,1 }, { 1,1,1 }}; + + if (node->has_adjust_transform) { + ufbxi_mul_scale_real(&t, node->adjust_post_scale); + } + + ufbxi_mul_scale(&t, scaling); + + if (node->has_adjust_transform) { + ufbxi_mul_scale_real(&t, node->adjust_pre_scale); + } + + return t.scale; +} + ufbxi_noinline static ufbx_transform ufbxi_get_transform(const ufbx_props *props, ufbx_rotation_order order, const ufbx_node *node, const ufbx_vec3 *translation_scale) { ufbx_vec3 scale_pivot = ufbxi_find_vec3(props, ufbxi_ScalingPivot, 0.0f, 0.0f, 0.0f); @@ -22727,9 +22870,13 @@ ufbxi_noinline static ufbx_transform ufbxi_get_transform(const ufbx_props *props ufbxi_add_translate(&t, scale_offset); ufbxi_sub_translate(&t, rot_pivot); - ufbxi_mul_inv_rotate(&t, post_rotation, UFBX_ROTATION_ORDER_XYZ); - ufbxi_mul_rotate(&t, rotation, order); - ufbxi_mul_rotate(&t, pre_rotation, UFBX_ROTATION_ORDER_XYZ); + if (node->use_rotation_space) { + ufbxi_mul_inv_rotate(&t, post_rotation, UFBX_ROTATION_ORDER_XYZ); + ufbxi_mul_rotate(&t, rotation, order); + ufbxi_mul_rotate(&t, pre_rotation, UFBX_ROTATION_ORDER_XYZ); + } else { + ufbxi_mul_rotate(&t, rotation, UFBX_ROTATION_ORDER_XYZ); + } ufbxi_add_translate(&t, rot_pivot); ufbxi_add_translate(&t, rot_offset); @@ -22750,53 +22897,11 @@ ufbxi_noinline static ufbx_transform ufbxi_get_transform(const ufbx_props *props ufbxi_mirror_rotation(&t.rotation, node->adjust_mirror_axis); } - return t; -} + // Make sure the fast paths are identical to this function. + ufbxi_regression_assert(ufbxi_is_quat_equal(t.rotation, ufbxi_get_rotation(props, order, node))); + ufbxi_regression_assert(ufbxi_is_vec3_equal(t.scale, ufbxi_get_scale(props, node))); -ufbxi_noinline static ufbx_quat ufbxi_get_rotation(const ufbx_props *props, ufbx_rotation_order order, const ufbx_node *node) -{ - ufbx_vec3 rotation = ufbxi_find_vec3(props, ufbxi_Lcl_Rotation, 0.0f, 0.0f, 0.0f); - ufbx_vec3 pre_rotation = ufbxi_find_vec3(props, ufbxi_PreRotation, 0.0f, 0.0f, 0.0f); - ufbx_vec3 post_rotation = ufbxi_find_vec3(props, ufbxi_PostRotation, 0.0f, 0.0f, 0.0f); - - ufbx_transform t = { { 0,0,0 }, { 0,0,0,1 }, { 1,1,1 }}; - - if (node->has_adjust_transform) { - ufbxi_mul_rotate_quat(&t, node->adjust_post_rotation); - } - - ufbxi_mul_inv_rotate(&t, post_rotation, UFBX_ROTATION_ORDER_XYZ); - ufbxi_mul_rotate(&t, rotation, order); - ufbxi_mul_rotate(&t, pre_rotation, UFBX_ROTATION_ORDER_XYZ); - - if (node->has_adjust_transform) { - ufbxi_mul_rotate_quat(&t, node->adjust_pre_rotation); - } - - if (node->adjust_mirror_axis) { - ufbxi_mirror_rotation(&t.rotation, node->adjust_mirror_axis); - } - - return t.rotation; -} - -ufbxi_noinline static ufbx_vec3 ufbxi_get_scale(const ufbx_props *props, const ufbx_node *node) -{ - ufbx_vec3 scaling = ufbxi_find_vec3(props, ufbxi_Lcl_Scaling, 1.0f, 1.0f, 1.0f); - - ufbx_transform t = { { 0,0,0 }, { 0,0,0,1 }, { 1,1,1 }}; - - if (node->has_adjust_transform) { - ufbxi_mul_scale_real(&t, node->adjust_post_scale); - } - - ufbxi_mul_scale(&t, scaling); - - if (node->has_adjust_transform) { - ufbxi_mul_scale_real(&t, node->adjust_pre_scale); - } - - return t.scale; + return t; } ufbxi_noinline static ufbx_transform ufbxi_get_texture_transform(const ufbx_props *props) @@ -22853,6 +22958,10 @@ ufbxi_noinline static void ufbxi_update_node(ufbx_node *node, const ufbx_transfo node->euler_rotation = ufbxi_find_vec3(&node->props, ufbxi_Lcl_Rotation, 0.0f, 0.0f, 0.0f); if (!node->is_root) { + const bool rotation_active = ufbxi_find_int(&node->props, ufbxi_RotationActive, 1) != 0; + const bool rotation_limit_only = ufbxi_find_int(&node->props, ufbxi_RotationSpaceForLimitOnly, 0) != 0; + node->use_rotation_space = rotation_active && !rotation_limit_only; + const ufbx_vec3 *transform_scale = NULL; if (node->parent && node->parent->scale_helper) { transform_scale = &node->parent->scale_helper->local_transform.scale; @@ -27075,7 +27184,7 @@ ufbxi_nodiscard static ufbxi_noinline int ufbxi_bake_postprocess_quat(ufbxi_bake ufbx_quat ref = src.data[0].value; for (size_t i = 1; i < src.count; i++) { ufbx_quat v = src.data[i].value; - if (v.x != ref.x || v.y != ref.y || v.z != ref.z || v.y != ref.w) { + if (v.x != ref.x || v.y != ref.y || v.z != ref.z || v.w != ref.w) { constant = false; break; } @@ -33093,4 +33202,3 @@ ufbx_abi ufbx_vec3 ufbx_get_weighted_face_normal(const ufbx_vertex_vec3 *positio #elif defined(__GNUC__) #pragma GCC diagnostic pop #endif - diff --git a/ufbx-c/ufbx.h b/ufbx-c/ufbx.h index 939952f..9af0335 100644 --- a/ufbx-c/ufbx.h +++ b/ufbx-c/ufbx.h @@ -267,7 +267,7 @@ struct ufbx_converter { }; // `ufbx_source_version` contains the version of the corresponding source file. // HINT: The version can be compared numerically to the result of `ufbx_pack_version()`, // for example `#if UFBX_VERSION >= ufbx_pack_version(0, 12, 0)`. -#define UFBX_HEADER_VERSION ufbx_pack_version(0, 21, 2) +#define UFBX_HEADER_VERSION ufbx_pack_version(0, 23, 0) #define UFBX_VERSION UFBX_HEADER_VERSION // -- Basic types @@ -967,7 +967,11 @@ struct ufbx_node { // True if the node has a non-identity `geometry_transform`. bool has_geometry_transform; - // If `true` the transform is adjusted by ufbx, not enabled by default. + // If `true`, you should apply `RotationOrder`, `PreRotation` and `PostRotation` properties. + // See `UFBX_RotationOrder`, `UFBX_PreRotation`, `UFBX_PostRotation`. + bool use_rotation_space; + + // If `true`, the transform is adjusted by ufbx, not enabled by default. // See `adjust_pre_rotation`, `adjust_pre_scale`, `adjust_post_rotation`, // and `adjust_post_scale`. bool has_adjust_transform; @@ -1627,6 +1631,7 @@ struct ufbx_camera { // Bone attached to a `ufbx_node`, provides the logical length of the bone // but most interesting information is directly in `ufbx_node`. +// NOTE: The FBX format calls these Skeleton node attributes. struct ufbx_bone { union { ufbx_element element; struct { ufbx_string name; @@ -1647,6 +1652,7 @@ struct ufbx_bone { }; // Empty/NULL/locator connected to a node, actual details in `ufbx_node` +// NOTE: The FBX format calls these Null node attributes. struct ufbx_empty { union { ufbx_element element; struct { ufbx_string name; @@ -3500,11 +3506,12 @@ typedef enum ufbx_exporter UFBX_ENUM_REPR { UFBX_EXPORTER_BLENDER_BINARY, UFBX_EXPORTER_BLENDER_ASCII, UFBX_EXPORTER_MOTION_BUILDER, + UFBX_EXPORTER_UFBX_WRITE, UFBX_ENUM_FORCE_WIDTH(UFBX_EXPORTER) } ufbx_exporter; -UFBX_ENUM_TYPE(ufbx_exporter, UFBX_EXPORTER, UFBX_EXPORTER_MOTION_BUILDER); +UFBX_ENUM_TYPE(ufbx_exporter, UFBX_EXPORTER, UFBX_EXPORTER_UFBX_WRITE); typedef struct ufbx_application { ufbx_string vendor; @@ -3916,9 +3923,13 @@ typedef struct ufbx_scene_settings { ufbx_time_protocol time_protocol; ufbx_snap_mode snap_mode; - // Original settings (?) + // Original `axes.up` value for the scene. + // NOTE: This may be `UFBX_COORDINATE_AXIS_UNKNOWN` if not specified in the file. ufbx_coordinate_axis original_axis_up; + + // Original `unit_meters` value for the scene. ufbx_real original_unit_meters; + } ufbx_scene_settings; struct ufbx_scene { diff --git a/ufbx/__init__.py b/ufbx/__init__.py index faca7e1..ad2e3cd 100644 --- a/ufbx/__init__.py +++ b/ufbx/__init__.py @@ -9,6 +9,8 @@ AnimStack, ApertureMode, AspectMode, + BakedAnim, + BakedNode, BlendChannel, BlendDeformer, BlendMode, @@ -76,6 +78,8 @@ "AnimStack", "ApertureMode", "AspectMode", + "BakedAnim", + "BakedNode", "BlendChannel", "BlendDeformer", "BlendMode", diff --git a/ufbx/__init__.pyi b/ufbx/__init__.pyi index 708e7b3..f4f6d20 100644 --- a/ufbx/__init__.pyi +++ b/ufbx/__init__.pyi @@ -275,6 +275,8 @@ class AnimStack(Element): def time_end(self) -> float: ... @property def layers(self) -> list[AnimLayer]: ... + @property + def anim(self) -> Anim | None: ... class AnimLayer(Element): @property @@ -305,9 +307,46 @@ class AnimCurve(Element): def min_time(self) -> float: ... @property def max_time(self) -> float: ... + def evaluate(self, time: float, default_value: float = 0.0) -> float: ... class Anim(Element): ... +class BakedNode: + @property + def typed_id(self) -> int: ... + @property + def constant_translation(self) -> bool: ... + @property + def constant_rotation(self) -> bool: ... + @property + def constant_scale(self) -> bool: ... + @property + def translation_times(self) -> np.ndarray[Any, Any]: ... + @property + def translation_values(self) -> np.ndarray[Any, Any]: ... + @property + def rotation_times(self) -> np.ndarray[Any, Any]: ... + @property + def rotation_values(self) -> np.ndarray[Any, Any]: ... + @property + def scale_times(self) -> np.ndarray[Any, Any]: ... + @property + def scale_values(self) -> np.ndarray[Any, Any]: ... + +class BakedAnim: + @property + def playback_time_begin(self) -> float: ... + @property + def playback_time_end(self) -> float: ... + @property + def playback_duration(self) -> float: ... + @property + def key_time_min(self) -> float: ... + @property + def key_time_max(self) -> float: ... + @property + def nodes(self) -> list[BakedNode]: ... + class SkinDeformer(Element): @property def name(self) -> str: ... @@ -518,6 +557,11 @@ class Scene: @property def root_node(self) -> Node | None: ... @property + def anim(self) -> Anim | None: ... + def bake_anim( + self, anim: Anim | None = None, resample_rate: float = 30.0, trim_start_time: bool = False + ) -> BakedAnim: ... + @property def axes(self) -> CoordinateAxes: ... def find_node(self, name: str) -> Node | None: ... def find_material(self, name: str) -> Material | None: ... @@ -540,6 +584,9 @@ class Node(Element): @property def is_root(self) -> bool: ... @property + def typed_id(self) -> int: ... + def evaluate_transform(self, time: float, anim: Anim | None = None) -> Transform: ... + @property def world_transform(self) -> np.ndarray[Any, Any]: ... @property def local_transform(self) -> np.ndarray[Any, Any]: ... @@ -767,5 +814,5 @@ class Material(Element): @property def fbx_vector_displacement(self) -> MaterialMap: ... -def load_file(filename: str) -> Scene: ... +def load_file(filename: str, ignore_geometry: bool = False, ignore_embedded: bool = False) -> Scene: ... def load_memory(data: bytes) -> Scene: ... diff --git a/ufbx/_ufbx.pyx b/ufbx/_ufbx.pyx index 58cd2bc..31377b4 100644 --- a/ufbx/_ufbx.pyx +++ b/ufbx/_ufbx.pyx @@ -389,6 +389,10 @@ cdef extern from "ufbx-c/ufbx.h": pass ctypedef struct ufbx_constraint: pass + ctypedef struct ufbx_anim: + pass + ctypedef struct ufbx_baked_anim: + pass # Find functions from ufbx ufbx_node* ufbx_find_node(const ufbx_scene *scene, const char *name) @@ -586,6 +590,32 @@ cdef extern from "ufbx_wrapper.h": double ufbx_wrapper_constraint_get_weight(const ufbx_constraint *constraint) bint ufbx_wrapper_constraint_get_active(const ufbx_constraint *constraint) + # Animation evaluation & baking + ufbx_scene* ufbx_wrapper_load_file_opts(const char *filename, bint ignore_geometry, bint ignore_embedded, char **error_msg) + uint32_t ufbx_wrapper_node_get_typed_id(const ufbx_node *node) + ufbx_anim* ufbx_wrapper_scene_get_default_anim(const ufbx_scene *scene) + ufbx_anim* ufbx_wrapper_anim_stack_get_anim(const ufbx_anim_stack *anim_stack) + double ufbx_wrapper_evaluate_curve(const ufbx_anim_curve *anim_curve, double time, double default_value) + void ufbx_wrapper_evaluate_transform(const ufbx_anim *anim, const ufbx_node *node, double time, double *translation3, double *rotation4, double *scale3) + ufbx_baked_anim* ufbx_wrapper_bake_anim(const ufbx_scene *scene, const ufbx_anim *anim, double resample_rate, bint trim_start_time, char **error_msg) + void ufbx_wrapper_free_baked_anim(ufbx_baked_anim *bake) + double ufbx_wrapper_baked_anim_get_playback_time_begin(const ufbx_baked_anim *bake) + double ufbx_wrapper_baked_anim_get_playback_time_end(const ufbx_baked_anim *bake) + double ufbx_wrapper_baked_anim_get_playback_duration(const ufbx_baked_anim *bake) + double ufbx_wrapper_baked_anim_get_key_time_min(const ufbx_baked_anim *bake) + double ufbx_wrapper_baked_anim_get_key_time_max(const ufbx_baked_anim *bake) + size_t ufbx_wrapper_baked_anim_get_num_nodes(const ufbx_baked_anim *bake) + uint32_t ufbx_wrapper_baked_node_get_typed_id(const ufbx_baked_anim *bake, size_t index) + bint ufbx_wrapper_baked_node_get_constant_translation(const ufbx_baked_anim *bake, size_t index) + bint ufbx_wrapper_baked_node_get_constant_rotation(const ufbx_baked_anim *bake, size_t index) + bint ufbx_wrapper_baked_node_get_constant_scale(const ufbx_baked_anim *bake, size_t index) + size_t ufbx_wrapper_baked_node_get_num_translation_keys(const ufbx_baked_anim *bake, size_t index) + size_t ufbx_wrapper_baked_node_get_num_rotation_keys(const ufbx_baked_anim *bake, size_t index) + size_t ufbx_wrapper_baked_node_get_num_scale_keys(const ufbx_baked_anim *bake, size_t index) + void ufbx_wrapper_baked_node_get_translation_keys(const ufbx_baked_anim *bake, size_t index, double *times, double *values3) + void ufbx_wrapper_baked_node_get_rotation_keys(const ufbx_baked_anim *bake, size_t index, double *times, double *values4) + void ufbx_wrapper_baked_node_get_scale_keys(const ufbx_baked_anim *bake, size_t index, double *times, double *values3) + # Python classes class UfbxError(Exception): @@ -1279,6 +1309,16 @@ cdef class AnimStack(Element): raise RuntimeError("Scene is closed") return ufbx_wrapper_anim_stack_get_name(self._anim_stack).decode('utf-8', errors='replace') + @property + def anim(self): + """Animation descriptor for this stack (for evaluation/baking)""" + if self._scene._closed: + raise RuntimeError("Scene is closed") + cdef ufbx_anim* anim = ufbx_wrapper_anim_stack_get_anim(self._anim_stack) + if anim != NULL: + return Anim._create(self._scene, anim) + return None + @property def time_begin(self): """Start time of the animation""" @@ -1426,10 +1466,116 @@ cdef class AnimCurve(Element): raise RuntimeError("Scene is closed") return ufbx_wrapper_anim_curve_get_max_time(self._anim_curve) + def evaluate(self, double time, double default_value=0.0): + """Evaluate the curve value at a given time (seconds). + + Args: + time: Time in seconds to sample the curve at. + default_value: Value returned when the curve has no keyframes. + + Returns: + The interpolated curve value as a float. + """ + if self._scene._closed: + raise RuntimeError("Scene is closed") + return ufbx_wrapper_evaluate_curve(self._anim_curve, time, default_value) + cdef class Anim(Element): - """Animation definition (placeholder for future implementation)""" - pass + """Animation descriptor used for evaluation and baking. + + Obtained from `Scene.anim` (default animation) or `AnimStack.anim`. + """ + cdef Scene _scene + cdef ufbx_anim* _anim + + @staticmethod + cdef Anim _create(Scene scene, ufbx_anim* anim): + """Internal factory method""" + cdef Anim obj = Anim.__new__(Anim) + obj._scene = scene + obj._anim = anim + return obj + + +cdef class BakedNode: + """Baked transform animation for a single node. + + Keyframe data is copied into numpy arrays on creation, so this object + stays valid after the scene or bake is freed. + + Rotation quaternions use ufbx component order (x, y, z, w). + """ + cdef readonly unsigned int typed_id + cdef readonly bint constant_translation + cdef readonly bint constant_rotation + cdef readonly bint constant_scale + cdef readonly object translation_times # (N,) float64 seconds + cdef readonly object translation_values # (N, 3) float64 + cdef readonly object rotation_times # (M,) float64 seconds + cdef readonly object rotation_values # (M, 4) float64 (x, y, z, w) + cdef readonly object scale_times # (K,) float64 seconds + cdef readonly object scale_values # (K, 3) float64 + + @staticmethod + cdef BakedNode _create(ufbx_baked_anim* bake, size_t index): + """Internal factory method: eagerly copies key data out of the bake.""" + cdef BakedNode obj = BakedNode.__new__(BakedNode) + obj.typed_id = ufbx_wrapper_baked_node_get_typed_id(bake, index) + obj.constant_translation = ufbx_wrapper_baked_node_get_constant_translation(bake, index) + obj.constant_rotation = ufbx_wrapper_baked_node_get_constant_rotation(bake, index) + obj.constant_scale = ufbx_wrapper_baked_node_get_constant_scale(bake, index) + + cdef size_t num_t = ufbx_wrapper_baked_node_get_num_translation_keys(bake, index) + cdef size_t num_r = ufbx_wrapper_baked_node_get_num_rotation_keys(bake, index) + cdef size_t num_s = ufbx_wrapper_baked_node_get_num_scale_keys(bake, index) + + cdef np.ndarray[np.float64_t, ndim=1] t_times = np.empty(num_t, dtype=np.float64) + cdef np.ndarray[np.float64_t, ndim=2] t_values = np.empty((num_t, 3), dtype=np.float64) + cdef np.ndarray[np.float64_t, ndim=1] r_times = np.empty(num_r, dtype=np.float64) + cdef np.ndarray[np.float64_t, ndim=2] r_values = np.empty((num_r, 4), dtype=np.float64) + cdef np.ndarray[np.float64_t, ndim=1] s_times = np.empty(num_s, dtype=np.float64) + cdef np.ndarray[np.float64_t, ndim=2] s_values = np.empty((num_s, 3), dtype=np.float64) + + if num_t > 0: + ufbx_wrapper_baked_node_get_translation_keys(bake, index, t_times.data, t_values.data) + if num_r > 0: + ufbx_wrapper_baked_node_get_rotation_keys(bake, index, r_times.data, r_values.data) + if num_s > 0: + ufbx_wrapper_baked_node_get_scale_keys(bake, index, s_times.data, s_values.data) + + obj.translation_times = t_times + obj.translation_values = t_values + obj.rotation_times = r_times + obj.rotation_values = r_values + obj.scale_times = s_times + obj.scale_values = s_values + return obj + + def __repr__(self): + return (f"BakedNode(typed_id={self.typed_id}, " + f"translation_keys={len(self.translation_times)}, " + f"rotation_keys={len(self.rotation_times)}, " + f"scale_keys={len(self.scale_times)})") + + +cdef class BakedAnim: + """Whole animation baked into resampled, linearly interpolable keyframes. + + Produced by `Scene.bake_anim()`. All keyframe data lives in numpy arrays + owned by the contained `BakedNode` objects. + """ + cdef readonly double playback_time_begin + cdef readonly double playback_time_end + cdef readonly double playback_duration + cdef readonly double key_time_min + cdef readonly double key_time_max + cdef readonly list nodes # list[BakedNode] + + def __repr__(self): + return (f"BakedAnim(nodes={len(self.nodes)}, " + f"playback_time_begin={self.playback_time_begin}, " + f"playback_time_end={self.playback_time_end})") cdef class SkinDeformer(Element): @@ -1912,6 +2058,64 @@ cdef class Scene: return Node._create(self, node) return None + @property + def anim(self): + """Default animation descriptor (all stacks/layers combined)""" + if self._closed: + raise RuntimeError("Scene is closed") + cdef ufbx_anim* anim = ufbx_wrapper_scene_get_default_anim(self._scene) + if anim != NULL: + return Anim._create(self, anim) + return None + + def bake_anim(self, Anim anim=None, double resample_rate=30.0, bint trim_start_time=False): + """Bake an animation into resampled per-node keyframes. + + Evaluates the full animation (all layers, property blending, and + transform inheritance) and returns linearly interpolable + translation/rotation/scale keys for every animated node. + + Args: + anim: Animation descriptor to bake (e.g. ``AnimStack.anim``). + Defaults to the scene's default animation. + resample_rate: Samples per second used for non-linear animation. + trim_start_time: Shift keyframe times so the animation starts at 0. + + Returns: + BakedAnim with a list of BakedNode entries holding numpy arrays. + """ + if self._closed: + raise RuntimeError("Scene is closed") + + cdef ufbx_anim* anim_ptr = NULL + if anim is not None: + anim_ptr = anim._anim + + cdef char* error_msg = NULL + cdef ufbx_baked_anim* bake = ufbx_wrapper_bake_anim(self._scene, anim_ptr, resample_rate, trim_start_time, &error_msg) + if bake == NULL: + err = error_msg.decode('utf-8') if error_msg != NULL else "Unknown error" + if error_msg != NULL: + free(error_msg) + raise UfbxError(f"Failed to bake animation: {err}") + + cdef BakedAnim result = BakedAnim.__new__(BakedAnim) + cdef size_t num_nodes = ufbx_wrapper_baked_anim_get_num_nodes(bake) + cdef list nodes = [] + cdef size_t i + try: + for i in range(num_nodes): + nodes.append(BakedNode._create(bake, i)) + result.playback_time_begin = ufbx_wrapper_baked_anim_get_playback_time_begin(bake) + result.playback_time_end = ufbx_wrapper_baked_anim_get_playback_time_end(bake) + result.playback_duration = ufbx_wrapper_baked_anim_get_playback_duration(bake) + result.key_time_min = ufbx_wrapper_baked_anim_get_key_time_min(bake) + result.key_time_max = ufbx_wrapper_baked_anim_get_key_time_max(bake) + finally: + ufbx_wrapper_free_baked_anim(bake) + result.nodes = nodes + return result + @property def axes(self): """Scene coordinate axes (right, up, front). Returns CoordinateAxes with CoordinateAxis members.""" @@ -2229,6 +2433,47 @@ cdef class Node(Element): raise RuntimeError("Scene is closed") return ufbx_wrapper_node_is_root(self._node) + @property + def typed_id(self): + """Index of this node in Scene.nodes (matches BakedNode.typed_id)""" + if self._scene._closed: + raise RuntimeError("Scene is closed") + return ufbx_wrapper_node_get_typed_id(self._node) + + def evaluate_transform(self, double time, Anim anim=None): + """Evaluate this node's local transform at a given time (seconds). + + Args: + time: Time in seconds to evaluate the animation at. + anim: Animation descriptor to evaluate. Defaults to the scene's + default animation. + + Returns: + Transform with translation (Vec3), rotation (Quat, x/y/z/w) and + scale (Vec3) relative to the node's parent. + """ + if self._scene._closed: + raise RuntimeError("Scene is closed") + + cdef ufbx_anim* anim_ptr + if anim is not None: + anim_ptr = anim._anim + else: + anim_ptr = ufbx_wrapper_scene_get_default_anim(self._scene._scene) + if anim_ptr == NULL: + raise UfbxError("No animation available to evaluate") + + cdef double translation[3] + cdef double rotation[4] + cdef double scale[3] + ufbx_wrapper_evaluate_transform(anim_ptr, self._node, time, translation, rotation, scale) + + cdef Transform transform = Transform() + transform.translation = Vec3(translation[0], translation[1], translation[2]) + transform.rotation = Quat(rotation[0], rotation[1], rotation[2], rotation[3]) + transform.scale = Vec3(scale[0], scale[1], scale[2]) + return transform + @property def world_transform(self): """World transform matrix (4x4, column-major)""" @@ -3448,11 +3693,14 @@ cdef class Material(Element): # Module-level functions -def load_file(filename): +def load_file(filename, ignore_geometry=False, ignore_embedded=False): """Load FBX file and return Scene object Args: filename: Path to FBX file + ignore_geometry: Skip loading geometry data (vertices, indices, etc.) + for fast animation/skeleton-only loads. + ignore_embedded: Skip loading embedded content (textures, etc.). Returns: Scene object @@ -3465,7 +3713,11 @@ def load_file(filename): cdef char* error_msg = NULL cdef bytes filename_bytes = filename.encode('utf-8') - cdef ufbx_scene* scene = ufbx_wrapper_load_file(filename_bytes, &error_msg) + cdef ufbx_scene* scene + if ignore_geometry or ignore_embedded: + scene = ufbx_wrapper_load_file_opts(filename_bytes, ignore_geometry, ignore_embedded, &error_msg) + else: + scene = ufbx_wrapper_load_file(filename_bytes, &error_msg) if scene == NULL: err = error_msg.decode('utf-8') if error_msg != NULL else "Unknown error" diff --git a/ufbx/src/ufbx_wrapper.c b/ufbx/src/ufbx_wrapper.c index dba25f8..059943b 100644 --- a/ufbx/src/ufbx_wrapper.c +++ b/ufbx/src/ufbx_wrapper.c @@ -1094,3 +1094,184 @@ double ufbx_wrapper_constraint_get_weight(const ufbx_constraint *constraint) { bool ufbx_wrapper_constraint_get_active(const ufbx_constraint *constraint) { return constraint ? constraint->active : false; } + +// Animation evaluation & baking + +static char* ufbx_wrapper_copy_error(const ufbx_error *error) { + size_t len = error->description.length; + char *msg = (char*)malloc(len + 1); + if (msg) { + memcpy(msg, error->description.data, len); + msg[len] = '\0'; + } + return msg; +} + +ufbx_scene* ufbx_wrapper_load_file_opts(const char *filename, bool ignore_geometry, bool ignore_embedded, char **error_msg) { + ufbx_load_opts opts = {0}; + opts.ignore_geometry = ignore_geometry; + opts.ignore_embedded = ignore_embedded; + opts.ignore_missing_external_files = true; + + ufbx_error error; + ufbx_scene *scene = ufbx_load_file(filename, &opts, &error); + if (!scene && error_msg) { + *error_msg = ufbx_wrapper_copy_error(&error); + } + return scene; +} + +uint32_t ufbx_wrapper_node_get_typed_id(const ufbx_node *node) { + return node ? node->typed_id : 0; +} + +ufbx_anim* ufbx_wrapper_scene_get_default_anim(const ufbx_scene *scene) { + return scene ? scene->anim : NULL; +} + +ufbx_anim* ufbx_wrapper_anim_stack_get_anim(const ufbx_anim_stack *anim_stack) { + return anim_stack ? anim_stack->anim : NULL; +} + +double ufbx_wrapper_evaluate_curve(const ufbx_anim_curve *anim_curve, double time, double default_value) { + if (!anim_curve) return default_value; + return ufbx_evaluate_curve(anim_curve, time, default_value); +} + +void ufbx_wrapper_evaluate_transform(const ufbx_anim *anim, const ufbx_node *node, double time, + double *translation3, double *rotation4, double *scale3) { + ufbx_transform transform = ufbx_evaluate_transform(anim, node, time); + translation3[0] = transform.translation.x; + translation3[1] = transform.translation.y; + translation3[2] = transform.translation.z; + rotation4[0] = transform.rotation.x; + rotation4[1] = transform.rotation.y; + rotation4[2] = transform.rotation.z; + rotation4[3] = transform.rotation.w; + scale3[0] = transform.scale.x; + scale3[1] = transform.scale.y; + scale3[2] = transform.scale.z; +} + +ufbx_baked_anim* ufbx_wrapper_bake_anim(const ufbx_scene *scene, const ufbx_anim *anim, + double resample_rate, bool trim_start_time, char **error_msg) { + if (!scene) return NULL; + + ufbx_bake_opts opts = {0}; + opts.resample_rate = resample_rate; + opts.trim_start_time = trim_start_time; + + ufbx_error error; + ufbx_baked_anim *bake = ufbx_bake_anim(scene, anim ? anim : scene->anim, &opts, &error); + if (!bake && error_msg) { + *error_msg = ufbx_wrapper_copy_error(&error); + } + return bake; +} + +void ufbx_wrapper_free_baked_anim(ufbx_baked_anim *bake) { + if (bake) { + ufbx_free_baked_anim(bake); + } +} + +double ufbx_wrapper_baked_anim_get_playback_time_begin(const ufbx_baked_anim *bake) { + return bake ? bake->playback_time_begin : 0.0; +} + +double ufbx_wrapper_baked_anim_get_playback_time_end(const ufbx_baked_anim *bake) { + return bake ? bake->playback_time_end : 0.0; +} + +double ufbx_wrapper_baked_anim_get_playback_duration(const ufbx_baked_anim *bake) { + return bake ? bake->playback_duration : 0.0; +} + +double ufbx_wrapper_baked_anim_get_key_time_min(const ufbx_baked_anim *bake) { + return bake ? bake->key_time_min : 0.0; +} + +double ufbx_wrapper_baked_anim_get_key_time_max(const ufbx_baked_anim *bake) { + return bake ? bake->key_time_max : 0.0; +} + +size_t ufbx_wrapper_baked_anim_get_num_nodes(const ufbx_baked_anim *bake) { + return bake ? bake->nodes.count : 0; +} + +static const ufbx_baked_node* ufbx_wrapper_get_baked_node(const ufbx_baked_anim *bake, size_t index) { + if (!bake || index >= bake->nodes.count) return NULL; + return &bake->nodes.data[index]; +} + +uint32_t ufbx_wrapper_baked_node_get_typed_id(const ufbx_baked_anim *bake, size_t index) { + const ufbx_baked_node *node = ufbx_wrapper_get_baked_node(bake, index); + return node ? node->typed_id : 0; +} + +bool ufbx_wrapper_baked_node_get_constant_translation(const ufbx_baked_anim *bake, size_t index) { + const ufbx_baked_node *node = ufbx_wrapper_get_baked_node(bake, index); + return node ? node->constant_translation : false; +} + +bool ufbx_wrapper_baked_node_get_constant_rotation(const ufbx_baked_anim *bake, size_t index) { + const ufbx_baked_node *node = ufbx_wrapper_get_baked_node(bake, index); + return node ? node->constant_rotation : false; +} + +bool ufbx_wrapper_baked_node_get_constant_scale(const ufbx_baked_anim *bake, size_t index) { + const ufbx_baked_node *node = ufbx_wrapper_get_baked_node(bake, index); + return node ? node->constant_scale : false; +} + +size_t ufbx_wrapper_baked_node_get_num_translation_keys(const ufbx_baked_anim *bake, size_t index) { + const ufbx_baked_node *node = ufbx_wrapper_get_baked_node(bake, index); + return node ? node->translation_keys.count : 0; +} + +size_t ufbx_wrapper_baked_node_get_num_rotation_keys(const ufbx_baked_anim *bake, size_t index) { + const ufbx_baked_node *node = ufbx_wrapper_get_baked_node(bake, index); + return node ? node->rotation_keys.count : 0; +} + +size_t ufbx_wrapper_baked_node_get_num_scale_keys(const ufbx_baked_anim *bake, size_t index) { + const ufbx_baked_node *node = ufbx_wrapper_get_baked_node(bake, index); + return node ? node->scale_keys.count : 0; +} + +void ufbx_wrapper_baked_node_get_translation_keys(const ufbx_baked_anim *bake, size_t index, double *times, double *values3) { + const ufbx_baked_node *node = ufbx_wrapper_get_baked_node(bake, index); + if (!node) return; + for (size_t i = 0; i < node->translation_keys.count; i++) { + const ufbx_baked_vec3 *key = &node->translation_keys.data[i]; + times[i] = key->time; + values3[i * 3 + 0] = key->value.x; + values3[i * 3 + 1] = key->value.y; + values3[i * 3 + 2] = key->value.z; + } +} + +void ufbx_wrapper_baked_node_get_rotation_keys(const ufbx_baked_anim *bake, size_t index, double *times, double *values4) { + const ufbx_baked_node *node = ufbx_wrapper_get_baked_node(bake, index); + if (!node) return; + for (size_t i = 0; i < node->rotation_keys.count; i++) { + const ufbx_baked_quat *key = &node->rotation_keys.data[i]; + times[i] = key->time; + values4[i * 4 + 0] = key->value.x; + values4[i * 4 + 1] = key->value.y; + values4[i * 4 + 2] = key->value.z; + values4[i * 4 + 3] = key->value.w; + } +} + +void ufbx_wrapper_baked_node_get_scale_keys(const ufbx_baked_anim *bake, size_t index, double *times, double *values3) { + const ufbx_baked_node *node = ufbx_wrapper_get_baked_node(bake, index); + if (!node) return; + for (size_t i = 0; i < node->scale_keys.count; i++) { + const ufbx_baked_vec3 *key = &node->scale_keys.data[i]; + times[i] = key->time; + values3[i * 3 + 0] = key->value.x; + values3[i * 3 + 1] = key->value.y; + values3[i * 3 + 2] = key->value.z; + } +} diff --git a/ufbx/src/ufbx_wrapper.h b/ufbx/src/ufbx_wrapper.h index 31bace3..f45c25a 100644 --- a/ufbx/src/ufbx_wrapper.h +++ b/ufbx/src/ufbx_wrapper.h @@ -287,6 +287,48 @@ int ufbx_wrapper_constraint_get_type(const ufbx_constraint *constraint); double ufbx_wrapper_constraint_get_weight(const ufbx_constraint *constraint); bool ufbx_wrapper_constraint_get_active(const ufbx_constraint *constraint); +// Animation evaluation & baking +typedef struct ufbx_anim ufbx_anim; +typedef struct ufbx_baked_anim ufbx_baked_anim; + +// Scene loading with content filtering (skip geometry/embedded data for fast animation-only loads) +ufbx_scene* ufbx_wrapper_load_file_opts(const char *filename, bool ignore_geometry, bool ignore_embedded, char **error_msg); + +// Node identity (index into ufbx_scene.nodes[]) +uint32_t ufbx_wrapper_node_get_typed_id(const ufbx_node *node); + +// Anim handles +ufbx_anim* ufbx_wrapper_scene_get_default_anim(const ufbx_scene *scene); +ufbx_anim* ufbx_wrapper_anim_stack_get_anim(const ufbx_anim_stack *anim_stack); + +// Curve / transform evaluation +double ufbx_wrapper_evaluate_curve(const ufbx_anim_curve *anim_curve, double time, double default_value); +void ufbx_wrapper_evaluate_transform(const ufbx_anim *anim, const ufbx_node *node, double time, + double *translation3, double *rotation4, double *scale3); + +// Whole-animation baking (resampled linear keys per node) +ufbx_baked_anim* ufbx_wrapper_bake_anim(const ufbx_scene *scene, const ufbx_anim *anim, + double resample_rate, bool trim_start_time, char **error_msg); +void ufbx_wrapper_free_baked_anim(ufbx_baked_anim *bake); +double ufbx_wrapper_baked_anim_get_playback_time_begin(const ufbx_baked_anim *bake); +double ufbx_wrapper_baked_anim_get_playback_time_end(const ufbx_baked_anim *bake); +double ufbx_wrapper_baked_anim_get_playback_duration(const ufbx_baked_anim *bake); +double ufbx_wrapper_baked_anim_get_key_time_min(const ufbx_baked_anim *bake); +double ufbx_wrapper_baked_anim_get_key_time_max(const ufbx_baked_anim *bake); +size_t ufbx_wrapper_baked_anim_get_num_nodes(const ufbx_baked_anim *bake); + +// Baked node access (index into baked node list, values copied into caller buffers) +uint32_t ufbx_wrapper_baked_node_get_typed_id(const ufbx_baked_anim *bake, size_t index); +bool ufbx_wrapper_baked_node_get_constant_translation(const ufbx_baked_anim *bake, size_t index); +bool ufbx_wrapper_baked_node_get_constant_rotation(const ufbx_baked_anim *bake, size_t index); +bool ufbx_wrapper_baked_node_get_constant_scale(const ufbx_baked_anim *bake, size_t index); +size_t ufbx_wrapper_baked_node_get_num_translation_keys(const ufbx_baked_anim *bake, size_t index); +size_t ufbx_wrapper_baked_node_get_num_rotation_keys(const ufbx_baked_anim *bake, size_t index); +size_t ufbx_wrapper_baked_node_get_num_scale_keys(const ufbx_baked_anim *bake, size_t index); +void ufbx_wrapper_baked_node_get_translation_keys(const ufbx_baked_anim *bake, size_t index, double *times, double *values3); +void ufbx_wrapper_baked_node_get_rotation_keys(const ufbx_baked_anim *bake, size_t index, double *times, double *values4); +void ufbx_wrapper_baked_node_get_scale_keys(const ufbx_baked_anim *bake, size_t index, double *times, double *values3); + #ifdef __cplusplus } #endif From 51d38552d75d12bba20aaa76504b1ebcb84a72a8 Mon Sep 17 00:00:00 2001 From: Jack Yao Date: Mon, 27 Jul 2026 09:21:43 -0500 Subject: [PATCH 2/3] Commit animation test fixture so CI runs the animation tests The animation evaluation tests are guarded by skipif(not FIXTURE.exists()), so with the fixture ignored they silently skipped in CI and during the cibuildwheel test step. Add a .gitignore exception for the small maya_game_sausage fixture (61 KB, MIT-licensed upstream ufbx test data). --- .gitignore | 3 +++ .../maya_game_sausage_7500_binary_wiggle.fbx | Bin 0 -> 62976 bytes 2 files changed, 3 insertions(+) create mode 100644 tests/fixtures/maya_game_sausage_7500_binary_wiggle.fbx diff --git a/.gitignore b/.gitignore index 1f2efb7..7f29678 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/tests/fixtures/maya_game_sausage_7500_binary_wiggle.fbx b/tests/fixtures/maya_game_sausage_7500_binary_wiggle.fbx new file mode 100644 index 0000000000000000000000000000000000000000..82712549b422a6b15e2506680db51e6a85bcad25 GIT binary patch literal 62976 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 Date: Wed, 29 Jul 2026 09:28:46 -0500 Subject: [PATCH 3/3] isolate testing to python 3.11 and 3.13 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 997251b..8a0c4a3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.11", "3.13"] steps: - uses: actions/checkout@v4