diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eb9a8a9..8a0c4a3 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.11", "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/.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/.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/fixtures/maya_game_sausage_7500_binary_wiggle.fbx b/tests/fixtures/maya_game_sausage_7500_binary_wiggle.fbx new file mode 100644 index 0000000..8271254 Binary files /dev/null and b/tests/fixtures/maya_game_sausage_7500_binary_wiggle.fbx differ 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