fix(gltf_auto_export): fix animation export (#148)
* fix(gltf_auto_export): added fix for correct "deep-copying" of objects & hierarchy so that animated meshes & nested hierarchies get exported correctly * test(gltf_auto_export): * added visual testing to check for overall regression : * added screenshoting in bevy app * added visual compare with reference screenshot on python side * added testing of correct export of animated models * added testing of correct export of empties * added testing of correct export of nested hierarchies * added testing of correct export of blueprints, with & without components etc * fixes #147
This commit is contained in:
parent
dfc2be8c50
commit
7ffcd55f5d
Binary file not shown.
Binary file not shown.
Binary file not shown.
After Width: | Height: | Size: 645 KiB |
|
@ -1,15 +1,93 @@
|
||||||
pub mod in_game;
|
pub mod in_game;
|
||||||
use std::time::Duration;
|
use std::{
|
||||||
|
fs::{self},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use bevy_gltf_blueprints::{AnimationPlayerLink, BlueprintName};
|
||||||
pub use in_game::*;
|
pub use in_game::*;
|
||||||
|
|
||||||
use bevy::{prelude::*, time::common_conditions::on_timer};
|
use bevy::{
|
||||||
|
prelude::*, render::view::screenshot::ScreenshotManager, time::common_conditions::on_timer,
|
||||||
|
window::PrimaryWindow,
|
||||||
|
};
|
||||||
use bevy_gltf_worlflow_examples_common::{AppState, GameState};
|
use bevy_gltf_worlflow_examples_common::{AppState, GameState};
|
||||||
|
|
||||||
|
use crate::{TupleTestF32, UnitTest};
|
||||||
|
|
||||||
fn start_game(mut next_app_state: ResMut<NextState<AppState>>) {
|
fn start_game(mut next_app_state: ResMut<NextState<AppState>>) {
|
||||||
next_app_state.set(AppState::AppLoading);
|
next_app_state.set(AppState::AppLoading);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if the export from Blender worked correctly, we should have animations (simplified here by using AnimationPlayerLink)
|
||||||
|
// if the export from Blender worked correctly, we should have an Entity called "Cylinder" that has two components: UnitTest, TupleTestF32
|
||||||
|
// if the export from Blender worked correctly, we should have an Entity called "Blueprint4_nested" that has a child called "Blueprint3" that has a "BlueprintName" component with value Blueprint3
|
||||||
|
|
||||||
|
fn validate_export(
|
||||||
|
parents: Query<&Parent>,
|
||||||
|
children: Query<&Children>,
|
||||||
|
names: Query<&Name>,
|
||||||
|
blueprints: Query<(Entity, &Name, &BlueprintName)>,
|
||||||
|
animation_player_links: Query<(Entity, &AnimationPlayerLink)>,
|
||||||
|
exported_cylinder: Query<(Entity, &Name, &UnitTest, &TupleTestF32)>,
|
||||||
|
empties_candidates: Query<(Entity, &Name, &GlobalTransform)>,
|
||||||
|
) {
|
||||||
|
let animations_found = !animation_player_links.is_empty();
|
||||||
|
|
||||||
|
let mut cylinder_found = false;
|
||||||
|
if let Ok(nested_cylinder) = exported_cylinder.get_single() {
|
||||||
|
let parent_name = names
|
||||||
|
.get(parents.get(nested_cylinder.0).unwrap().get())
|
||||||
|
.unwrap();
|
||||||
|
cylinder_found = parent_name.to_string() == *"Cube.001"
|
||||||
|
&& nested_cylinder.1.to_string() == *"Cylinder"
|
||||||
|
&& nested_cylinder.3 .0 == 75.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut nested_blueprint_found = false;
|
||||||
|
for (entity, name, blueprint_name) in blueprints.iter() {
|
||||||
|
if name.to_string() == *"Blueprint4_nested" && blueprint_name.0 == *"Blueprint4_nested" {
|
||||||
|
if let Ok(cur_children) = children.get(entity) {
|
||||||
|
for child in cur_children.iter() {
|
||||||
|
if let Ok((_, child_name, child_blueprint_name)) = blueprints.get(*child) {
|
||||||
|
if child_name.to_string() == *"Blueprint3"
|
||||||
|
&& child_blueprint_name.0 == *"Blueprint3"
|
||||||
|
{
|
||||||
|
nested_blueprint_found = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut empty_found = false;
|
||||||
|
for (_, name, _) in empties_candidates.iter() {
|
||||||
|
if name.to_string() == *"Empty" {
|
||||||
|
empty_found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
"bevy_diagnostics.json",
|
||||||
|
format!(
|
||||||
|
"{{ \"animations\": {}, \"cylinder_found\": {} , \"nested_blueprint_found\": {}, \"empty_found\": {} }}",
|
||||||
|
animations_found, cylinder_found, nested_blueprint_found, empty_found
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.expect("Unable to write file");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_screenshot(
|
||||||
|
main_window: Query<Entity, With<PrimaryWindow>>,
|
||||||
|
mut screenshot_manager: ResMut<ScreenshotManager>,
|
||||||
|
) {
|
||||||
|
screenshot_manager
|
||||||
|
.save_screenshot_to_disk(main_window.single(), "screenshot.png")
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
fn exit_game(mut app_exit_events: ResMut<Events<bevy::app::AppExit>>) {
|
fn exit_game(mut app_exit_events: ResMut<Events<bevy::app::AppExit>>) {
|
||||||
app_exit_events.send(bevy::app::AppExit);
|
app_exit_events.send(bevy::app::AppExit);
|
||||||
}
|
}
|
||||||
|
@ -18,11 +96,14 @@ pub struct GamePlugin;
|
||||||
impl Plugin for GamePlugin {
|
impl Plugin for GamePlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.add_systems(Update, (spawn_test).run_if(in_state(GameState::InGame)))
|
app.add_systems(Update, (spawn_test).run_if(in_state(GameState::InGame)))
|
||||||
|
.add_systems(Update, validate_export)
|
||||||
|
.add_systems(Update, generate_screenshot.run_if(on_timer(Duration::from_secs_f32(0.2)))) // TODO: run once
|
||||||
.add_systems(OnEnter(AppState::MenuRunning), start_game)
|
.add_systems(OnEnter(AppState::MenuRunning), start_game)
|
||||||
|
.add_systems(OnEnter(AppState::AppRunning), setup_game)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
exit_game.run_if(on_timer(Duration::from_secs_f32(0.5))),
|
exit_game.run_if(on_timer(Duration::from_secs_f32(0.5))),
|
||||||
) // shut down the app after this time
|
) // shut down the app after this time
|
||||||
.add_systems(OnEnter(AppState::AppRunning), setup_game);
|
;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,11 +7,11 @@ use std::ops::Range;
|
||||||
|
|
||||||
#[derive(Component, Reflect, Default, Debug)]
|
#[derive(Component, Reflect, Default, Debug)]
|
||||||
#[reflect(Component)]
|
#[reflect(Component)]
|
||||||
struct UnitTest;
|
pub struct UnitTest;
|
||||||
|
|
||||||
#[derive(Component, Reflect, Default, Debug, Deref, DerefMut)]
|
#[derive(Component, Reflect, Default, Debug, Deref, DerefMut)]
|
||||||
#[reflect(Component)]
|
#[reflect(Component)]
|
||||||
struct TupleTestF32(f32);
|
pub struct TupleTestF32(pub f32);
|
||||||
|
|
||||||
#[derive(Component, Reflect, Default, Debug, Deref, DerefMut)]
|
#[derive(Component, Reflect, Default, Debug, Deref, DerefMut)]
|
||||||
#[reflect(Component)]
|
#[reflect(Component)]
|
||||||
|
|
|
@ -24,12 +24,38 @@ def remove_unwanted_custom_properties(object):
|
||||||
if cp in object:
|
if cp in object:
|
||||||
del object[cp]
|
del object[cp]
|
||||||
|
|
||||||
|
def duplicate_object(object):
|
||||||
|
obj_copy = object.copy()
|
||||||
|
if object.data:
|
||||||
|
data = object.data.copy()
|
||||||
|
obj_copy.data = data
|
||||||
|
if object.animation_data and object.animation_data.action:
|
||||||
|
obj_copy.animation_data.action = object.animation_data.action.copy()
|
||||||
|
return obj_copy
|
||||||
|
|
||||||
|
#also removes unwanted custom_properties for all objects in hiearchy
|
||||||
|
def duplicate_object_recursive(object, parent, collection):
|
||||||
|
original_name = object.name
|
||||||
|
object.name = original_name + "____bak"
|
||||||
|
copy = duplicate_object(object)
|
||||||
|
copy.name = original_name
|
||||||
|
collection.objects.link(copy)
|
||||||
|
|
||||||
|
remove_unwanted_custom_properties(copy)
|
||||||
|
|
||||||
|
if parent:
|
||||||
|
copy.parent = parent
|
||||||
|
|
||||||
|
for child in object.children:
|
||||||
|
duplicate_object_recursive(child, copy, collection)
|
||||||
|
return copy
|
||||||
|
|
||||||
|
|
||||||
# copies the contents of a collection into another one while replacing library instances with empties
|
# copies the contents of a collection into another one while replacing library instances with empties
|
||||||
def copy_hollowed_collection_into(source_collection, destination_collection, parent_empty=None, filter=None, library_collections=[], addon_prefs={}):
|
def copy_hollowed_collection_into(source_collection, destination_collection, parent_empty=None, filter=None, library_collections=[], addon_prefs={}):
|
||||||
collection_instances_combine_mode = getattr(addon_prefs, "collection_instances_combine_mode")
|
collection_instances_combine_mode = getattr(addon_prefs, "collection_instances_combine_mode")
|
||||||
legacy_mode = getattr(addon_prefs, "export_legacy_mode")
|
legacy_mode = getattr(addon_prefs, "export_legacy_mode")
|
||||||
collection_instances_combine_mode= collection_instances_combine_mode
|
collection_instances_combine_mode= collection_instances_combine_mode
|
||||||
|
|
||||||
for object in source_collection.objects:
|
for object in source_collection.objects:
|
||||||
if filter is not None and filter(object) is False:
|
if filter is not None and filter(object) is False:
|
||||||
continue
|
continue
|
||||||
|
@ -54,19 +80,13 @@ def copy_hollowed_collection_into(source_collection, destination_collection, par
|
||||||
if parent_empty is not None:
|
if parent_empty is not None:
|
||||||
empty_obj.parent = parent_empty
|
empty_obj.parent = parent_empty
|
||||||
else:
|
else:
|
||||||
# we create a copy of our object, to leave the original one as it is
|
|
||||||
original_name = object.name
|
# we create a copy of our object and its children, to leave the original one as it is
|
||||||
object.name = original_name + "____bak"
|
if object.parent == None:
|
||||||
copy = object.copy()
|
copy = duplicate_object_recursive(object, None, destination_collection)
|
||||||
copy.name = original_name
|
|
||||||
remove_unwanted_custom_properties(copy)
|
|
||||||
|
|
||||||
if parent_empty is not None:
|
if parent_empty is not None:
|
||||||
copy.parent = parent_empty
|
copy.parent = parent_empty
|
||||||
destination_collection.objects.link(copy)
|
|
||||||
else:
|
|
||||||
# root_objects.append(object)
|
|
||||||
destination_collection.objects.link(copy)
|
|
||||||
|
|
||||||
# for every sub-collection of the source, copy its content into a new sub-collection of the destination
|
# for every sub-collection of the source, copy its content into a new sub-collection of the destination
|
||||||
for collection in source_collection.children:
|
for collection in source_collection.children:
|
||||||
|
|
|
@ -219,4 +219,24 @@ def did_export_parameters_change(current_params, previous_params):
|
||||||
print("copying ", key,"to", components_holder)
|
print("copying ", key,"to", components_holder)
|
||||||
if not key in components_holder:
|
if not key in components_holder:
|
||||||
components_holder[key] = components[key]
|
components_holder[key] = components[key]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# potentially useful alternative
|
||||||
|
def duplicate_object2(object, original_name):
|
||||||
|
print("copy object", object)
|
||||||
|
|
||||||
|
with bpy.context.temp_override(object=object, active_object = object):
|
||||||
|
bpy.ops.object.duplicate(linked=False)
|
||||||
|
new_obj = bpy.context.active_object
|
||||||
|
|
||||||
|
print("new obj", new_obj, "bpy.context.view_layer", bpy.context.view_layer.objects)
|
||||||
|
for obj in bpy.context.view_layer.objects:
|
||||||
|
print("obj", obj)
|
||||||
|
bpy.context.view_layer.update()
|
||||||
|
new_obj.name = original_name
|
||||||
|
|
||||||
|
if object.animation_data:
|
||||||
|
print("OJECT ANIMATION")
|
||||||
|
new_obj.animation_data.action = object.animation_data.action.copy()
|
||||||
|
|
||||||
|
return new_obj
|
|
@ -4,3 +4,6 @@ addopts = -svv
|
||||||
testpaths =
|
testpaths =
|
||||||
tests
|
tests
|
||||||
|
|
||||||
|
# dependencies:
|
||||||
|
# pytest_blender
|
||||||
|
# pixelmatch
|
|
@ -5,6 +5,9 @@ import json
|
||||||
import pytest
|
import pytest
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
from pixelmatch.contrib.PIL import pixelmatch
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def setup_data(request):
|
def setup_data(request):
|
||||||
print("\nSetting up resources...")
|
print("\nSetting up resources...")
|
||||||
|
@ -25,7 +28,13 @@ def setup_data(request):
|
||||||
|
|
||||||
if os.path.exists(other_materials_path):
|
if os.path.exists(other_materials_path):
|
||||||
shutil.rmtree(other_materials_path)"""
|
shutil.rmtree(other_materials_path)"""
|
||||||
|
diagnostics_file_path = os.path.join(root_path, "bevy_diagnostics.json")
|
||||||
|
if os.path.exists(diagnostics_file_path):
|
||||||
|
os.remove(diagnostics_file_path)
|
||||||
|
|
||||||
|
screenshot_observed_path = os.path.join(root_path, "screenshot.png")
|
||||||
|
if os.path.exists(screenshot_observed_path):
|
||||||
|
os.remove(screenshot_observed_path)
|
||||||
|
|
||||||
request.addfinalizer(finalizer)
|
request.addfinalizer(finalizer)
|
||||||
|
|
||||||
|
@ -65,7 +74,8 @@ def test_export_complex(setup_data):
|
||||||
export_output_folder="./models",
|
export_output_folder="./models",
|
||||||
export_scene_settings=True,
|
export_scene_settings=True,
|
||||||
export_blueprints=True,
|
export_blueprints=True,
|
||||||
export_legacy_mode=False
|
export_legacy_mode=False,
|
||||||
|
export_animations=True
|
||||||
)
|
)
|
||||||
# blueprint1 => has an instance, got changed, should export
|
# blueprint1 => has an instance, got changed, should export
|
||||||
# blueprint2 => has NO instance, but marked as asset, should export
|
# blueprint2 => has NO instance, but marked as asset, should export
|
||||||
|
@ -80,6 +90,8 @@ def test_export_complex(setup_data):
|
||||||
assert os.path.exists(os.path.join(models_path, "library", "Blueprint3.glb")) == True
|
assert os.path.exists(os.path.join(models_path, "library", "Blueprint3.glb")) == True
|
||||||
assert os.path.exists(os.path.join(models_path, "library", "Blueprint4_nested.glb")) == True
|
assert os.path.exists(os.path.join(models_path, "library", "Blueprint4_nested.glb")) == True
|
||||||
assert os.path.exists(os.path.join(models_path, "library", "Blueprint5.glb")) == False
|
assert os.path.exists(os.path.join(models_path, "library", "Blueprint5.glb")) == False
|
||||||
|
assert os.path.exists(os.path.join(models_path, "library", "Blueprint6_animated.glb")) == True
|
||||||
|
assert os.path.exists(os.path.join(models_path, "library", "Blueprint7_hierarchy.glb")) == True
|
||||||
|
|
||||||
# now run bevy
|
# now run bevy
|
||||||
bla = "cargo run --features bevy/dynamic_linking"
|
bla = "cargo run --features bevy/dynamic_linking"
|
||||||
|
@ -91,3 +103,23 @@ def test_export_complex(setup_data):
|
||||||
return_code = subprocess.call(["cargo", "run", "--features", "bevy/dynamic_linking"], cwd=root_path)
|
return_code = subprocess.call(["cargo", "run", "--features", "bevy/dynamic_linking"], cwd=root_path)
|
||||||
print("RETURN CODE OF BEVY APP", return_code)
|
print("RETURN CODE OF BEVY APP", return_code)
|
||||||
assert return_code == 0
|
assert return_code == 0
|
||||||
|
|
||||||
|
with open(os.path.join(root_path, "bevy_diagnostics.json")) as diagnostics_file:
|
||||||
|
diagnostics = json.load(diagnostics_file)
|
||||||
|
print("diagnostics", diagnostics)
|
||||||
|
assert diagnostics["animations"] == True
|
||||||
|
assert diagnostics["cylinder_found"] == True
|
||||||
|
assert diagnostics["empty_found"] == True
|
||||||
|
|
||||||
|
# last but not least, do a visual compare
|
||||||
|
screenshot_expected_path = os.path.join(root_path, "expected_screenshot.png")
|
||||||
|
screenshot_observed_path = os.path.join(root_path, "screenshot.png")
|
||||||
|
img_a = Image.open(screenshot_expected_path)
|
||||||
|
img_b = Image.open(screenshot_observed_path)
|
||||||
|
img_diff = Image.new("RGBA", img_a.size)
|
||||||
|
mismatch = pixelmatch(img_a, img_b, img_diff, includeAA=True)
|
||||||
|
print("image mismatch", mismatch)
|
||||||
|
assert mismatch < 50
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue